Licença

Este livro está disponível sob a licença Creative Commons Atribuição-NãoComercial 3.0 Não Adaptada. Esta é uma tradução do ThinkJulia, que tem a mesma licença. Uma lista das diferenças na tradução deste livro estão disponíveis no Apéndice C.

Os autores do ThinkJulia são:

Ben Lauwens é professor de matemática na Rolay Military Academy (RMA Bélgica). Tem um doutorado em engenheria e um mestrado pela KU Leuven e RMA, e um bacharelado pela RMA.

Allen Downey é professor de ciência da computação na Olin College of Engineering. Ele lecionou na Wellesley College, Colby College e U.C. Berkeley. Tem um doutorado em ciência da computação pela U.C. Berkeley e mestrado e bacharelado pelo MIT.

Os tradutores para a versão em português são:

Abel Soares Siqueira é professor de matemática na Universidade Federal do Paraná.

Gustavo Sarturi é aluno de graduação em matemática industrial na Universidade Federal do Paraná.

João Okimoto é aluno de graduação em ciência da computação na Universidade Federal do Paraná.

Kally Chung, mestranda em métodos numéricos da engenharia na Universidade Federal do Paraná.

Dedicatória

Para Emeline, Arnaud e Tibo.

Prefácio

Em Janeiro de 2018 eu comecei a preparar um curso de programação voltado à estudantes sem experiência de programação. Eu queria usar o Julia, mas descobri que não existia livro com o objetivo de aprender a programar com Julia como a primeira linguagem de programação. Existem tutoriais muito bons que explicam os conceitos chave do Julia, mas nenhum deles dá atenção suficiente a como aprender a pensar como um programador.

Eu conhecia o livro Think Python de Allen Downey, que contém todos os ingredientes chave para aprender a programar corretamente. No entanto, este livro foi baseado na linguagem de programação Python. O meu primeiro rascunho das anotações do curso foi um caldeirão de todos os tipos de trabalhos de referência, mas quanto mais eu trabalhava nele, mais o seu conteúdo começava a se assemelhar aos capítulos de Think Python. Logo após isso, a ideia de desenvolver minhas anotações do curso como uma versão deste livro adaptado ao Julia se concretizou.

Todo o material estava disponível em notebooks Jupyter em um repositório no Github. Depois que mandei uma mensagem ao site do Julia Discourse sobre o progresso do meu curso, o feedback foi fantástico. Um livro sobre conceitos básicos de programação com Julia sendo a primeira linguagem de programação era aparentemente um elo perdido no universo Julia. Eu contatei Allen para perguntar se eu poderia começar uma adaptação oficial do Think Python para o Julia, e sua resposta foi imediata: “Vá em frente!” ele me colocou em contato com o seu editor da O’Reilly Media, e um ano depois, eu estava colocando os toques finais neste livro.

Foi uma jornada e tanto. Em Agosto de 2018 o Julia v1.0 foi lançado, e como todos meus companheiros que programam em Julia, eu tive que fazer uma migração do código. Todos os exemplos nesse livro foram testados durante a conversão dos arquivos fonte para arquivos AsciiDoc compativeís com a O’Reilly. Tanto o conjunto de ferramentas e o código exemplo teve que ser compatibilizado com o Julia v1.0. Por sorte, não há aulas para dar em Agosto…​.

Eu espero que você aproveite trabalhar com este livro, e que ele o ajude a aprender a programar e a pensar como um cientista da computação, pelo menos um pouquinho.

Por que Julia?

O Julia foi originalmente lançada em 2012 por Alan Edelman, Stefan Karpinski, Jeff Bezanson, e Viral Shah. Ela é uma linguagem de programação gratuita e open source.

Escolher uma linguagem de programação é sempre subjetivo. Para mim, as seguintes características do Julia são decisivas:

  • O Julia é desenvolvida como uma linguagem de programação de alta performance.

  • O Julia usa despacho múltiplo, que permite ao programador escolher diferentes padrões de programação adaptados à aplicação.

  • O Julia é uma linguagem dinamicamente tipada que pode ser facilmente usada interativamente.

  • O Julia possui uma boa sintaxe de alto nível que é fácil de aprender.

  • O Julia é uma linguagem de programação de tipo opcional, cujos tipos de dados (definidos pelo usuário) tornam o código mais claro e robusto.

  • O Julia possui uma biblioteca padrão extendida e numerosos pacotes de terceiros que estão disponíveis.

O Julia é uma linguagem de programação única pois resolve o chamado "problema de duas linguagens". Nenhuma outra linguagem de programação é necessária para escrever código de alta performance. Isso não significa que isso acontece automaticamente. É de responsabilidade do programador otimizar o código que forma um gargalo, mas isso pode ser feito no próprio Julia.

Para Quem é Este Livro?

Este livro é para qualquer um que quer aprender a programar. Nenhum conhecimento prévio é requerido.

Novos conceitos são introduzidos gradualmente e tópicos mais avançados são descritos em capítulos posteriores.

Introdução à programação em Julia (Think Julia) pode ser usado para um curso de um semestre em nível de ensino médio ou de universidade.

Convenções Adotadas Neste Livro

As seguintes conveções foram adotadas neste livro:

Itálico

indica novos termos, URLs, endereços de email, nomes de arquivo e extensões de arquivo.

Comprimento constante

Usado para listagens de programas, assim como dentro de paragráfos para referir a elementos de programas como variáveis ou nomes de função, banco de dados, tipos de dado, variáveis de ambiente, declarações e palavras-chave.

Comprimento constante em negrito

Indica comandos ou outro texto que deve ser literalmente digitado pelo usuário.

Comprimento constante itálico

Indica texto que deve ser substituido por valores fornecidos pelo usuário ou por valores determinados pelo contexto.

Dica

Este elemento signfica uma dica ou sugestão.

Nota

Este elemento significa uma observação geral.

Atenção

Este elemento indica um aviso ou cuidado.

Usando Exemplos de Código

Todo código usado neste livro está disponível em um repositório Git no Github: https://github.com/JuliaIntro/JuliaIntroBR.jl. Se você não esta familiarizado com o Git, ele é um sistema de controle de versão que permite ao usuário acompanhar os arquivos que fazem parte de um projeto. Um conjunto de arquivos sob controle do Git é chamado de “repositório”. O Github é um serviço de hospedamento que fornece armazenamento para repositórios Git e uma interface web conveniente.

Um pacote de conveniência é fornecido que pode ser diretamente adicionado ao Julia. Apenas digite add https://github.com/JuliaIntro/JuliaIntroBR.jl No REPL em modo Pkg, veja [turtles].

A maneira mais fácil de executar código em Julia é indo para https://repl.it e iniciar uma sessão gratuita. Tanto o REPL quanto um editor estão disponíveis. Se você quiser ter o Julia instalado localmente no seu computador, você pode fazer o download do JuliaPro gratuitamente de Julia Computing. Ele consiste de uma versão recente do Julia, um ambiente interativo de desenvolvimento baseado no Atom e um número de pacotes Julia pré-instalados. Se você quiser se aventurar ainda mais, você pode fazer o download do Julia em https://julialang.org, instalar o editor que você quiser (ex. Atom ou Visual Studio Code), e ativar plug-ins para integração do Julia. Para uma instalação local, você também pode adicionar o pacote IJulia e executar um notebook Jupyter no seu computador.

Agradecimentos

Eu realmente gostaria de agradecer ao Allen por escrever Think Python e permitir que eu pudesse adaptar o seu livro para o Julia. Seu entusiasmo é contagiante!

Eu também gostaria de agradecer aos revisores técnicos para este livro, que fizeram muitas sugestões utéis: Tim Besard, Bart Janssens, e David P. Sanders.

Obrigado à Melissa Potter da O’Reilly Media, que fez deste livro melhor. Você me forçou a fazer as coisas certo e fazer este livro o mais original possível.

Obrigado a Matt Hacker da O’Reilly Media, que me ajudou com o conjunto de ferramentas Atlas e alguns problemas de destacação de sintaxe.

Obrigado a todos os estudantes que trabalharam com uma versão inicial deste livro e todos os contribuidores (listados abaixo) que mandaram correções e sugestões.

Lista de Contribuidores

Se você tem uma sugestão ou correção, porfavor mande um email à ben.lauwens@gmail.com ou abra um issue em GitHub. Se eu fizer uma mudança baseada no seu feedback, irei adicioná-lo na lista de contribuidores (a menos que você peça para ser omitido).

Me diga com qual versão do livro você esta trabalhando, e qual formato. Se você incluir ao menos parte da frase aonde o erro aparece, isso facilitará a minha busca. Páginas e número de seção também ajudam, mas não são tão facéis de trabalhar. Obrigado!

  • Scott Jones apontou à mudança de nome de Void para Nothing, e isso iniciou a migração para o Julia v1.0.

  • Robin Deits achou alguns erros de digitação em Variáveis, Expressões e Declarações.

  • Mark Schmitz sugeriu ligar a sintaxe de destacação.

  • Zigu Zhao achou alguns bugs em Strings.

  • Oleg Soloviev achou um erro na URL para adicionar o pacote ThinkJulia.

  • Aaron Ang achou alguns problema de renderização e nomeação.

  • Sergey Volkov achou um link quebrado em Iteração.

  • Sean McAllister sugeriu mencionar o excelente pacote BenchmarkTools.

  • Carlos Bolech mandou uma longa lista de correções e sugestões.

  • Krishna Kumar corrigiu o exemplo de Markov em Subtipagem.

1. O Caminho da Programação

O objetivo deste livro é ensinar você a pensar como um cientista da computação. Essa maneira de pensar combina algumas das melhores ferramentas da matemática, engenharia e ciências naturais. Como matemáticos, cientistas da computação usam linguagens formais para denotar ideias (especificamente cálculos). Como engenheiros, projetam coisas, montando componentes em sistemas e avaliando ganhos e perdas entre as alternativas. Como cientistas, observam o comportamento de sistemas complexos, formulam hipóteses e testam predições.

A habilidade mais importante para um cientista da computação é a resolução de problemas. Resolver problemas significa ter habilidade para formular problemas, pensar em soluções com criatividade, e expressar a solução clara e precisamente. Como se vê, o processo de aprendizagem para programar é uma excelente oportunidade de praticar a habilidade de resolução de problemas. Por essa razão esse capítulo tem como título "O caminho da programação".

Por um lado, você estará aprendendo a programar, uma habilidade muito útil por si só. Por outro, você usará a programação como um significado para alcançar um objetivo. Na medida que formos evoluindo, esse fim ficará mais claro.

O que é um Programa?

Um programa é uma sequência de instruções que especifica como deve ser a computação. A computação poderá ser algo matemático, como resolver um sistema de equações ou encontrar as raízes de um polinômio, mas pode ser também uma computação simbólica, como uma busca ou substituição de texto num documento, ou algo gráfico, como o processamento de uma imagem ou reprodução de um vídeo.

Os detalhes são diferentes em diferentes linguagens de programação, mas algumas instruções básicas aparecem em praticamente em todos os idiomas:

Entrada (Input)

Coleta os dados do teclado, de um arquivo, de uma rede ou de algum dispositivo.

Saída (Output)

Exibe os dados na tela, salva em um documento, envia através da rede, etc.

Matemáticos

Realiza operações matemática básicas como adição e multiplicação.

Execução Condicional

Verifica certas condições e executa o código apropriado.

Repetição

Realiza alguma ação repetidamente, geralmente com alguma variação.

Acredite ou não, isso é tudo que existe. Todo programa que você já usou, por mais complicado que seja, é composto de instruções que são muito similares a essas. Portanto, você pode pensar em programação como o processo de dividir uma tarefa grande e complexa em subtarefas cada vez menores até que as subtarefas sejam simples o suficiente para serem executadas com uma dessas instruções básicas.

Executando o Julia

Um dos desafios de começar com o Julia é que talvez você precise instalá-lo e softwares relacionados no seu computador. Se você está familiarizado com o seu sistema operacional, e especificamente se você está confortável com a interface das linhas de comando, você não terá problemas em instalar o Julia. Mas, para iniciantes, pode ser assustador aprender sobre administração de sistemas operacionais e programação ao mesmo tempo.

Para evitar esse problema, eu recomendo que você comece a executar o Julia em um navegador. Depois, quando se sentir mais confortável com o Julia, eu irei sugerir instalar o Julia em seu computador.

No navegador, você pode executar o Repl.it. Nenhuma instalação é necessária - Apenas escolha um navegador, faça o log in e comece a programar (veja Editores online).

O REPL do Julia (Read-Eval-Print Loop, que traduzido ao pé da letra, significa Ler, Avaliar e Repetição de impressão) é um programa que lê e executa os códigos do Julia. Você pode começar o REPL abrindo um terminal e escrevendo julia na linha de comando (depois de instalar o Julia). Alternativamente, o Repl.it já abre um terminal junto com o editor. Quando executar, você deverá ver uma saída como essa:

               _
   _       _ _(_)_     |  Documentation: https://docs.julialang.org
  (_)     | (_) (_)    |
   _ _   _| |_  __ _   |  Type "?" for help, "]?" for Pkg help.
  | | | | | | |/ _` |  |
  | | |_| | | | (_| |  |  Version 1.4.2 (2020-05-23)
 _/ |\__'_|_|_|\__'_|  |  Official https://julialang.org/ release
|__/                   |

julia>

As primeiras linhas contém informações sobre o REPL, que deve ser diferente para você. Porém, você deve checar que o número da versão seja pelo menos 1.0.0.

A última linha é um prompt que indica que o REPL está pronto para você inserir o código. Se você digitar uma linha de código e pressionar Enter, o REPL exibirá o seguinte resultado:

julia> 1 + 1
2

Os trechos de códigos podem ser copiados e colados literalmente, incluindo o prompt julia> e qualquer saída.

Agora você está pronto para começar. Daqui em diante, irei assumir que você sabe como iniciar o Julia REPL e executar o código.

O Primeiro Programa

Tradicionalmente, o primeiro programa que você escreve em uma nova linguagem de programação é chamado de "Olá, Mundo!" porque tudo que faz é exibir as palavras "Olá, Mundo!". Em Julia, se parece com isso:

julia> println("Olá, Mundo!")
Olá, Mundo!

Este é um exemplo de um comando de impressão, embora não imprima nada no papel. Ele exibe o resultado na tela.

As aspas no programa marcam o início e o final do texto a ser exibido; eles não aparecem no resultado.

Os parênteses indicam que println é uma função. Nós chegaremos em funções no [Capítulo 3].

Operações Aritméticas

Depois de "Olá, Mundo!" o próximo passo é a aritmética. Julia fornece operadores, que são símbolos que representam cálculos como adição e multiplicação.

Os operadores operators +, -, and * executam adição, subtração e multiplicação, como nos seguintes exemplos:

julia> 40 + 2
42
julia> 43 - 1
42
julia> 6 * 7
42

O operador / executa divisão:

julia> 84 / 2
42.0

Talvez você deva estar se perguntando porquê o resultado é 42.0 invés de 42. Eu irei explicar na próxima seção.

Finalmente, o operador ^ executa exponenciação; isto é, eleva um número a uma potência:

julia> 6^2 + 6
42

Valores e Tipos

Um valor é uma das coisas mais básicas que um programa trabalha, como uma letra ou um número. Alguns valores que vimos até agora são 2, 42.0, e "Olá, Mundo!".

Esse valores pertencem a diferentes tipos: 2 é um inteiro, 42.0 é um número de ponto flutuante, e "Olá, Mundo!" é uma string, é chamada assim porque os caracteres que contém são amarrados.

Se você não tem certeza de que tipo é um determinado valor, o REPL pode dizer a você:

julia> typeof(2)
Int64
julia> typeof(42.0)
Float64
julia> typeof("Hello, World!")
String

Inteiros pertencem ao tipo Int64, strings pertencem a String, e pontos flutuantes pertencem a Float64.

E quanto a valores como "2" e "42.0"? Eles parecem números, mas estão entre parênteses como strings. Eles também são strings:

julia> typeof("2")
String
julia> typeof("42.0")
String

Quando você digita um número inteiro grande, você pode ficar tentado a usar vírgulas entre os grupos de dígitos, como em 1,000,000. Isto não é um inteiro permitido em Julia, mas é permitido:

julia> 1,000,000
(1, 0, 0)

Não era o que esperávamos! Julia analisa 1,000,000 como uma sequência separada por vírgula de inteiros. Iremos aprender mais sobre este tipo de sequência depois.

Contudo, você pode obter o resultado esperado usando 1_000_000.

Linguagens Formais e Naturais

Linguagens Naturais são aquelas linguagens que as pessoas falam, como Inglês, Espanhol, Português e o Francês. Elas não são projetadas por pessoas (embora pessoas tentem lhes impor alguma ordem); elas evoluíram naturalmente.

Linguagens Formais são linguagens projetadas por pessoas para aplicações específicas. Por exemplo, a notação que os matemáticos utilizam é uma linguagem formal que em particular é boa para denotar a relação entre números e símbolos. Químicos usam uma linguagem formal para representar a estrutura química das moléculas. E mais importante, linguagens de programação são linguagens formais projetadas para expressar cálculos.

Linguagens formais tendem a ter regras rígidas de sintaxe que governam a estrutura das declarações. Por exemplo, em matemática a declaração \(3 + 3 = 6\) tem sintaxe correta, mas \(3 += 3 \$ 6\) não. Em química, \(\mathrm{H_2O}\) é uma fórmula sintaticamente correta, mas \(\mathrm{_2Zz}\) não.

Regras de sintaxe são fornecidas em dois tipos, pertencentes a símbolos e estruturas. Símbolos são elementos básicos da linguagem, como palavras, números e elementos químicos. Um dos problemas com \(3 += 3 \$ 6\) é que \(\$\) não é um símbolo permitido em matemática (pelo menos até onde eu sei). Da mesma forma, \(\mathrm{_2Zz}\) não é permitido pois não há elemento com abreviação \(\mathrm{Zz}\).

O segundo tipo de regra de sintaxe pertence à maneira que símbolos são combinados. A equação \(3 +=3\) não é permitida porque mesmo que \(+\) e \(=\) sejam símbolos permitidos, você não pode ter um logo após o outro. Da mesma forma, em uma fórmula química, o subscrito vem depois do nome do elemento, e não antes.

Essa é um@ sentença bem estruturada em portuguê$ com 5ímb0l05 inválidos. Essa frase possui todos os símbolos válidos, mas com estrutura invalida.

Quando você lê uma sentença em Português ou uma declaração numa linguagem formal, você tem que descrever a estrutura (apesar de que em uma linguagem natural você faz isso subconscientemente). Esse processo é chamado de análise.

Embora as linguagens formais e naturais tenham muito recursos em comuns — símbolos, estruturas, e sintaxes — existem algumas diferenças:

Ambiguidade

As linguagens naturais são cheias de ambiguidades, com as quais as pessoas lidam usando pistas contextuais e outras informações. As linguagens formais são projetadas para serem quase ou completamente inequívocas, o que significa que qualquer afirmação tem exatamente um significado, independentemente do contexto.

Redundância

Para compensar a ambiguidade e reduzir os mal-entendidos, as linguagens naturais empregam muita redundância. Como resultado, elas geralmente são detalhadas. Linguagens formais são menos redundantes e mais concisas.

Literalidade

As línguas naturais estão cheias de expressões idiomáticas e metáforas. Se eu disser: "Caiu a ficha!", provavelmente não há ficha e nada está caindo (nesse idioma significa que alguém entendeu alguma coisa após um período de confusão). Linguagens formais significam exatamente o que dizem.

Como todos nós crescemos falando linguagens naturais, às vezes é difícil nos adaptarmos às línguas formais. A diferença entre linguagem formal e natural é como a diferença entre poesia e prosa, mas mais ainda:

Poesia

As palavras são usadas tanto pelos sons quanto pelo significado, e o poema inteiro cria um efeito ou resposta emocional. A ambiguidade não é apenas comum, mas muitas vezes deliberada.

Prosa

O significado literal das palavras é mais importante e a estrutura contribui com mais significado. A prosa é mais passível de análise do que a poesia, mas ainda é ambígua.

Programas

O significado de um programa de computador é inequívoco e literal e pode ser entendido inteiramente pela análise dos símbolos e da estrutura.

As linguagens formais são mais densas que as linguagens naturais, por isso leva mais tempo para lê-las. Além disso, a estrutura é importante, portanto nem sempre é melhor ler de cima para baixo, da esquerda para a direita. Em vez disso, você aprenderá a analisar o programa em sua cabeça, identificando os símbolos e interpretando a estrutura. Finalmente, os detalhes são importantes. Pequenos erros de ortografia e pontuação, com os quais você pode se dar bem em idiomas naturais, podem fazer uma grande diferença na linguagem formal.

Depuração

Programadores cometem erros. Por motivos lúdicos, erros de programação são chamados de bugs e o processo de rastreá-lo é chamado de deburação.

N.T.: Depurar em inglês é Debug, e comumente vemos a utilização de Debug e suas conjunções abrasileiradas. Note que Debugar (verbo) está incluso no dicionário brasileiro, mas Debug, o substantivo correspondente, não está.

A programação e, especialmente, a depuração, às vezes traz emoções fortes. Se você está lutando com um bug difícil, pode sentir raiva, desânimo ou vergonha.

Há evidências de que as pessoas respondem naturalmente aos computadores como se fossem pessoas. Quando eles funcionam bem, pensamos neles como companheiros de equipe e, quando são obstinados ou rudes, respondemos a eles da mesma maneira que respondemos a pessoas rudes e obstinadas.[1]

A preparação para essas reações pode ajudá-lo a lidar com elas. Uma abordagem é pensar no computador como um funcionário com certos pontos fortes, como velocidade e precisão, e pontos fracos particulares, como falta de empatia e incapacidade de entender o cenário geral.

Seu trabalho é ser um bom gerente: encontre maneiras de aproveitar os pontos fortes e atenuar os pontos fracos. E encontre maneiras de usar suas emoções para se envolver com o problema, sem deixar que suas reações interfiram na sua capacidade de trabalhar de maneira eficaz.

Aprender a depurar pode ser frustrante, mas é uma habilidade valiosa que é útil para muitas atividades além da programação. No final de cada capítulo, há uma seção, como esta, com minhas sugestões para depurar. Espero que eles ajudem!

Glossário

resolução de problemas

É o processo de formular o problema, encontrar uma solução e expressar-lo.

programa

Um é uma sequência de instruções que especifica um cálculo.

REPL

Um programa que repetidamente lê entradas, executa e exibe os resultados.

prompt

Caratecteres exibido pelo REPL para indicar que está pronto para receber informações do usuário.

declaração de impressão

Uma instrução que faz com o que o Julia REPL exiba o valor na tela.

operador

Um símbolo que representa um simples cálculo como adição, multiplicação, ou uma concatenação de strings.

valor

Uma ("valores" das unidades de dados mais básicas, como um número ou string, é o que um programa manipula.

tipo

Uma categoria de valores. Os tipos que vimos até agora são números inteiros (Int64), números de ponto flutuante (Float64) e seqüências de caracteres (String).

inteiros

Um tipo de que representa todos os números.

ponto-flutuante

Um tipo que representa números com pontos decimais.

string

Um tipo que representa uma sequência de caracteres.

linguagem natural

Qualquer uma das linguagens que as pessoas falam que se envolvem naturalmente.

linguagem formal

Qualquer uma das linguagens que as pessoas foram desenvolvidas para propósitos específicos, como representar ideias matemáticas ou programas de computador. Todas as linguagens de programação são formais.

sintaxe

As regras que governam a estrutura de um programa.

símbolo

Um dos elementos mais básicos de uma estrutura de sintaxes de um programa, análogo a uma palavra numa linguagem natural.

estrutura

A maneira que os símbolos são combinados.

analisar

Para examinar um programa e analisar uma estrutra de sintaxe.

bug

Um erro de um programa.

depurar

O processo de encontrar e corrigir bugs.

Exercícios

Dica

É uma boa ideia ler este livro na frente de um computador para que você possa experimentar os exemplos à medida que avança.

Exercício 1-1

Sempre que você estiver experimentando um novo recurso, tente cometer erros. Por exemplo, no programa "Olá, mundo!", o que acontece se você deixar de fora uma das aspas? E se você deixar de fora ambas? E se você soletrar println errado?

Esse tipo de experimento ajuda a lembrar o que você lê; também ajuda quando você está programando, porque você sabe o que significam as mensagens de erro. É melhor cometer erros agora e de propósito, e não mais tarde e acidentalmente.

  1. Em um comando de impressão, o que acontece se você deixar de fora um dos parênteses ou ambos?

  2. Se você estiver tentando imprimir uma sequência, o que acontece se você deixar de fora uma das aspas ou ambas?

  3. Você pode usar um sinal de menos para fazer um número negativo como -2. O que acontece se você colocar um sinal de mais antes de um número? E a respeito de 2++2?

  4. Em notação matemática, zeros à esquerda estão corretos, como em 02. O que acontece se você tentar isso em Julia?

  5. O que acontece se você tem dois valores com nenhum operador entre eles?

Exercício 1-2

Inicie o Julia REPL e use-o como uma calculadora.

  1. Quantos segundos existem em 42 minutos e 42 segundos?

  2. Quantas milhas existem em 10 quilômetros?

    Dica

    Uma milha equivale a 1,61.

  3. Se você corre uma corrida de 10 quilômetros em 37 minutos e 48 segundos, qual é o seu ritmo médio (tempo por milha em minutos e segundos)? Qual é a sua velocidade média em milhas por hora?

2. Variáveis, Expressões e Declarações

Uma das caractereísticas mais poderosas de uma linguagem de programação é a sua habilidade de manipular variáveis. Uma variável é um nome que refere-se a um valor.

Declarações de Atribuição

Uma declaração de atribuição cria uma nova variável e da à ela um valor:

julia> mensagem = "E agora para algo completamente diferente"
"E agora para algo completamente diferente"
julia> n = 17
17
julia> π_val = 3.141592653589793
3.141592653589793

Este exemplo faz três atribuições. A primeira atribui uma string à uma nova variável chamada mensagem; a segunda dá o inteiro 17 a n; a terceira atribui (aproximadamente) o valor de \(\pi\) a π_val (\pi TAB).

Um jeito comum de representar variáveis no papel é escrevendo o nome com uma flecha apontando para o seu valor. Este tipo de figura é denominado de diagrama de estado pois mostra em que estado cada variável encontra-se (imagine isso como sendo o estado de espirito da variável) Diagrama de estado mostra o resultado do exemplo anterior.

fig21
Figura 1. Diagrama de estado

Nomes de variáveis

Programadores geralmente escolhem nomes para suas variáveis que são significativos—eles documentam para o que a variável é usada.

Nomes de variáveis podem ser o quão longas você desejar. Eles podem conter quase todos os caracteres Unicode (veja [caracteres]), mas não podem começar com um número. É permitido usar letras maiúsculas, mas é convencional utilizar somente letras minúsculas para nomes de variáveis.

caracteres Unicode podem ser inseridos através do autocompletar do tab de abreviações similares à LaTeX no REPL do Julia.

O caractere sublinhado, _, pode aparecer em um nome. É comumente utilizado em nomes com multiplas palavras, como seu_nome ou velocidade_de_uma_andorinha_sem_carga.

Se um nome inválido for dado a uma variável, recebemos um erro de sintaxe:

julia> 76trombones = "grande desfile"
ERROR: syntax: "76" is not a valid function argument name
julia> mais@ = 1000000
ERROR: syntax: extra token "@" after end of expression
julia> struct = "Zimologia Teórica Avançado"
ERROR: syntax: unexpected "="

76trombones é inválido pois começa com um número. mais@ é inválido pois contem um caractere inválido, @. Mas o que há de errado com struct?

Acontece que struct é uma das palavras chave do Julia. O REPL usa palavras chaves para reconhecer a estrutura do programa, e elas não podem ser usadas como nomes de variáveis.

O Julia possui as seguintes palavras chave:

abstract type   baremodule   begin    break            catch
const           continue     do       else             elseif
end             export       false    finally          for
function        global       if       import           let
local           macro        module   mutable struct   primitive type
quote           return       true     using            struct
while

Nós não precisamos memorizar esta lista. Na maior parte dos ambientes desenvolvidos, palavras chave são exibidas em diferentes cores; se nós tentarmos utilizar uma como um nome de variável, saberemos.

Expressões e Declarações

Uma expressão é uma combinação de valores, variáveis e operadores. Um valor por si só é considerado uma expressão, assim como uma variável, então as expressões a seguir são todas válidas:

julia> 42
42
julia> n
17
julia> n + 25
42

Quando nós digitamos uma expressão no prompt, o REPL a avalia, o que significa que ele acha o valor da expressão. Neste exemplo, n tem valor 17 e n + 25 valor 42.

Uma declaração é uma unidade de código que possui um efeito, como criar uma variável ou exibir um valor.

julia> n = 17
17
julia> println(n)
17

A primeira linha é uma declaração de atribuição que da valor a n. A segunda linha é uma declaração de impressão que exibe o valor de n.

Quando nós digitamos uma declaração, o REPL a executa, o que significa que ele faz o que a declaração diz.

Modo Script

Até agora nós rodamos o Julia em modo interativo, o que significa que nós interagimos diretamente com o REPL. O modo interativo é uma boa maneira de começar, mas se nós estamos trabalhando com mais do que poucas linhas de código, ele pode ser inadequado.

A alternativa é salvar o código em um arquivo chamado script e em seguida rodar o Julia em modo script para executar o script. Por convenção, scripts Julia possuem nomes que terminam com .jl.

Se você sabe como criar e rodar um script no seu computador, você está pronto pra utilizar. No entanto eu recomendaria utilizar o Repl.it novamente. Abrir um arquivo de texto, escrever um script e salvá-lo com a extensão .jl. O script pode ser executado em um terminal com o comando julia nome_do_script.jl.

Como o Julia nos fornece ambos os modos, podemos testar pequenos trechos de código no modo interativo antes de colocá-los num script. Mas há diferenças entre o modo interativo e o modo script que podem ser um pouco confusas.

Por exemplo, se nós estamos usando o Julia como uma calculadora, podemos digitar

julia> milhas = 26.2
26.2
julia> milhas * 1.61
42.182

A primeira linha atribui um valor à milhas e exibe o seu valor. A segunda linha é uma expressão, então o REPL avalia-a e exibe o resultado. Acontece que uma maratona é aproximadamente 42 quilômetros.

Mas se digitarmos o mesmo código num script e rodá-lo, não temos resultado algum. No modo script uma expressão, por si só, não possui nenhum efeito visível. Na verdade o Julia avalia a expressão, mas não exibe o valor a não ser que nós mandemos-o:

milhas = 26.2
println(milhas * 1.61)

Esse comportamente pode ser um pouco confuso no início.

Um script geralmente contém uma sequência de declarações. Se existe mais de uma declaração, os resultados aparecem um de cada vez conforme as declarações são executadas.

Por exemplo, o script

println(1)
x = 2
println(x)

produz o resultado

1
2

A declaração de atribuição não produz nenhum resultado.

Exercício 2-1

Para verificar o seu entendimento, digite a seguinte sequência de declarações no REPL do Julia e veja o que eles fazem:

5
x = 5
x + 1

Agora coloque o mesmo trecho em um script e rode-o. Qual é o resultado? Modifique o script transformando cada expressão em uma declaração de impressão, e em seguida rode-o novamente.

Precedência de Operadores

Quando uma expressão contém mais de um operador, a ordem de avaliação depende da precedência de operador. Para operadores matemáticos, Julia segue convenções matemáticas. O acrônimo PEMDAS é uma maneira útil de lembrar as regras:

  • Parênteses possuem a maior precedência e podem ser usados para forçar uma expressão a ser avaliada na ordem que nós desejarmos. Já que expressões em parênteses são avaliadas primeiro, 2*(3-1) é 4, e (1+1)^(5-2) é 8. Nós também podemos utilizar parênteses para fazer com que uma expressão seja mais fácil de ler, como em (minuto * 100) / 60, mesmo que não altere o resultado.

  • Exponenciação possui a próxima precedência, então 1+2^3 é 9, e não 27, e 2*3^2 é 18, não 36.

  • Multiplicação e Divisão possuem maior precedência que Adição e Subtração. Então 2*3-1 é 5, não 4, e 6+4/2 é 8, não 5.

  • Operadores com a mesma precedência são avaliados da esquerda à direita (exceto exponenciação). Então na expressão graus / 2 * π, a divisão acontece primeiro e o resultado é multiplicado por π. Para dividir por \(2\pi\), podemos usar parênteses, escrevendo graus / 2 / π ou graus / 2π.

Dica

Não nos esforçamos muito para lembrar a precedência dos operadores. Se nós não consigormos lembrar ao olhar para a expressão, utilizamos parênteses para fazer com que seja óbvio.

Operações com Strings

Em geral, não podemos executar operações matemáticas em strings, mesmo se as strings pareçam-se com números, então o que se segue abaixo é inválido.

"2" - "1"    "ovos" / "fácil"    "terceiro" + "um encanto"

Mas existem duas exceções, * e ^.

O operador * executa a concatenação de strings, o que signifca que ele junta as strings ligando-as de ponta-a-ponta. Por exemplo:

julia> primeira_str = "tanga"
"tanga"
julia> segunda_str = "mandápio"
"mandápio"
julia> primeira_str * segunda_str
"tangamandápio"

O operador ^ também funciona em strings; ele executa a repetição. Por exemplo, "Spam"^3 é "SpamSpamSpam". Se um dos valores é uma string, o outro deve ser um inteiro.

Este uso de * e ^ faz sentido com analogia à multiplicação e exponenciação. Assim como 4^3 é equivalente a 4*4*4, nós esperamos que "Spam"^3 seja igual a "Spam"*"Spam"*"Spam", e é.

Comentários

À medida que os programas ficam maiores e mais complicados, eles ficam mais dificéis de ler. Linguagens formais são densas, e é comum ser difícil olhar para um pedaço de código e descobrir o que está acontecendo, ou por quê.

Por esta razão, é uma boa ideia adicionar anotações em nossos programas para explicar em uma linguagem natural o que o programa esta fazendo. Estas anotações são chamadas de comentários, e eles começam com o símbolo #:

# calcula a porcentagem da hora que já se passou
porcentagem = (minuto * 100) / 60

Neste caso, o comentário aparece numa linha por si só. Nós também podemos colocar comentários no final da linha:

porcentagem = (minuto * 100) / 60   # porcentagem de uma hora

Tudo a partir do # até o final da linha é ignorado e não causa efeito algum na execução do programa.

Comentários são bastante utéis quando eles documentam caractereísticas não óbvias do código. É razoável assumir que o leitor consegue descobrir o que o código faz; é mais útil explicar o por quê.

Esse comentário é redundante com o código e inútil:

v = 5   # atribui 5 a v

Esse comentário contém informação útil que não está no código:

v = 5   # velocidade em metros/segundo.
Atenção

Bons nomes de variável podem reduzir a necessidade de comentários, mas nomes longos podem fazer com que expressões complexas sejam dificéis de ler, então há uma compensação.

Depuração

Três tipos de erros podem ocorrer em um programa: erros de sintaxe, erros de execução e erros de semântica. É útil distinguir entre eles a fim de localizá-los mais rapidamente.

Erro de sintaxe

“sintaxe” refere-se à estrutura de um programa e as regras sobre esta estrutura. Por exemplo, parênteses precisam vir em pares correspondentes, então (1 + 2) é válido, mas 8) é um erro de sintaxe.

Se existe algum erro de sintaxe em algum lugar do nosso programa, o Julia exibirá uma mensagem de erro e encerrará, e nós não poderemos rodar o programa. Durante as primeiras semanas da sua carreira de programador, você deverá passar bastante tempo localizando erros de sintaxe. Conforme você vai ganhando experiência, você irá cometer menos erros e achá-los mais rapidamente.

Erro de execução

O segundo tipo de erro é o erro de execução, assim denominado pois o erro não aparece até que o programa finalmente esteja rodando. Estes erros também são chamados de exceções pois eles geralmente indicam que algo excepcional (e ruim) aconteceu.

Erros de execução são raros nos programas simples que você verá nos primeiros capítulos, então pode demorar um pouco até que você encontre um.

Erros de semântica

O terceiro tipo de erro é o de “semântica”, o que significa que ele é relacionado a significado. Se há um erro de semântica no seu programa, ele irá rodar sem gerar nenhuma mensagem de erro, mas não irá fazer a coisa certa. Ele irá fazer outra coisa. Mais especificamente, ele irá fazer o que nós mandamos-o fazer.

Identificar erros de semântica pode ser complicado, pois requer que nós trabalhemos em sentido contrário ao olhar o resultado do programa e tentar descobrir o que ele está fazendo.

Glossário

variável

Um nome que refere-se a um valor.

atribuição

Uma declaração que atribui um valor a uma variável.

diagrama de estado

Uma representação gráfica de um conjunto de variáveis e os valores que elas referem-se.

palavra-chave

Uma palavra reservada que é usada para analisar o programa; você não pode usar palavras-chave como if, function, e while como nomes de variáveis.

operando

Um dos valores no qual um operador opera sobre.

expressão

Uma combinação de variáveis, operadores, e valores que representam um único resultado.

avaliar

Simplificar uma expressão através da execução de operações a fim de produzir um único valor.

declaração

Uma seção de código que representa um comando ou ação. Até agora, as declarações que nós vimos são atribuições e declarações de impressão.

executar

Rodar uma declaração e fazer o que ela indica.

modo interativo

Um modo de usar o REPL do Julia digitando código no prompt.

modo script

Um modo de usar o Julia para ler código de um script e executá-lo.

script

Um programa guardado em um arquivo.

precedência de operador

Regras que governam a ordem na qual as expressões que envolvem múltiplos operadores matemáticos são avaliados.

concatenar

Juntar duas strings ponta-a-ponta.

comentário

Informação em um programa destinada a outros programadores (ou qualquer um lendo o código fonte) que não tem nenhum efeito na execução do programa.

erro de sintaxe

Um erro em um programa que faz com que seja impossível analisar (e consequentemente interpretar).

erro de execução ou exceção

Um erro que é detectado enquanto um programa está rodando.

semântica

O significado de um programa.

erro de semântica

Um erro em um programa que faz com que ele faça algo diferente do que o programador pretendia.

Exercícios

Exercício 2-2

Repetindo o conselho do capítulo anterior, sempre que você aprende novas funcionalidades, você deve experimentá-las no modo interativo e cometer erros de propósito para ver o que acontece de errado.

  1. Nós vimos que n = 42 é válido. E 42 = n ?

  2. E que tal x = y = 1?

  3. Em algumas linguagens toda declaração acaba com um ponto e vírgula, ;. O que acontece se você colocar um ponto e vírgula no final de uma declaração no Julia?

  4. E se você quiser colocar um ponto no final de uma declaração ?

  5. Em notação matemática, você pode multiplicar x e y assim: x y. O que acontece se você tentar isso em Julia? E 5x?

Exercício 2-3

Pratique usando o REPL do Julia como uma calculadora:

  1. O volume de uma esfera com raio \(r\) é \(\frac{4}{3} \pi r^3\). Qual é o volume de uma esfera de raio 5?

  2. Suponha que o preço de cobertura de um livro é R$ 24,95, mas as livrarias possuem desconto de 40%. A entrega custa R$ 3,00 para a primeira cópia e R$ 0,75 para cada cópia adicional. Qual é o preço total do atacado para 60 cópias?

  3. Se eu saio de casa às 6:52 da manhã e corro uma milha em um ritmo tranquilo (8min15s por milha), em seguida 3 milhas em ritmo (7min12seg por milha) e 1 milha em um ritmo tranquilo novamente, a que horas eu chego em casa para o café da manhã?

3. Funções

No contexto da programação, uma função é uma sequência nomeada de comandos que executa uma tarefa. Ao definir uma função, você especifica o nome e a sequência de comandos. Mais tarde, você pode "chamar" a função pelo nome dado.

Chamadas de funções

Nós já vimos um exemplo de chamada de função:

julia> println("Olá, Mundo!")
Olá, Mundo!

O nome da função é println. A expressão entre parênteses é o argumento da função.

É comum dizer que uma função "pega" um argumento e "retorna" um resultado que também é chamado de valor de retorno.

Julia fornece funções que convertem valores de um tipo para outro. A função parse recebe uma string e a converte em qualquer tipo de número se puder, ou dá erro caso contrário:

julia> parse(Int64, "32")
32
julia> parse(Float64, "3.14159")
3.14159
julia> parse(Int64, "Olá")
ERROR: ArgumentError: invalid base 10 digit 'O' in "Olá"

trunc pode converter valores de ponto flutuante em valores inteiros, mas não os arredonda; o comando trunca a parte fracionária:

julia> trunc(Int64, 3.99999)
3
julia> trunc(Int64, -2.3)
-2

float converte números inteiros em números de ponto flutuante:

julia> float(32)
32.0

Por último, string converte o seu argumento em uma string:

julia> string(32)
"32"
julia> string(3.14159)
"3.14159"

Funções matemáticas

Em Julia, a maioria das funções matemáticas tradicionais estão diretamente disponíveis:

razão = potência_sinal / potência_ruído
decibéis = 10 * log10(razão)

Este primeiro exemplo usa log10 para calcular uma relação sinal-ruído em decibéis (assumindo que potência_sinal e potência_ruído estão definidos). log, que calcula logaritmos naturais, também é dado.

radianos = 0.7
altura = sin(radianos)

Este segundo exemplo calcula o seno de radianos. O nome da variável é uma dica de que sin e de que as outras funções trigonométricas (cos, tan, etc.) recebem argumentos em radianos. Para converter de graus em radianos, divida por 180 e multiplique pelo \(\pi\):

julia> graus = 45
45
julia> radianos = graus / 180 * π
0.7853981633974483
julia> sin(radianos)
0.7071067811865475

O valor da variável π é uma aproximação do ponto flutuante de \(\pi\), com precisão de aproximadamente de 16 dígitos.

Se você conhece trigonometria, pode verificar o resultado anterior comparando-o com a raiz quadrada de dois dividido por dois:

julia> sqrt(2) / 2
0.7071067811865476

Composição

Até este momento, vimos os elementos de um programa—variáveis, expressões e comandos—isoladamente, sem detalhar em como combiná-las.

Um dos recursos mais úteis das linguagens de programação é a sua capacidade de manipular pequenos blocos de montar e compô-los. Por exemplo, o argumento de uma função pode ser qualquer tipo de expressão, incluindo operadores aritméticos:

x = sin(graus / 360 * 2 * π)

E até mesmo as chamadas de função:

x = exp(log(x+1))

Em quase todos os lugares onde você pode colocar um valor, pode-se colocar uma expressão arbitrária, com uma exceção: o lado esquerdo de uma atribuição tem que ser um nome de variável. Qualquer outra expressão do lado esquerdo resulta em um erro de sintaxe (veremos exceções a esta regra mais tarde).

julia> minutos = horas * 60 # correto
120
julia> horas * 60 = minutos # errado!
ERROR: syntax: "60" is not a valid function argument name

Adicionando novas funções

Embora tenhamos usado só as funções que vêm com Julia até agora, também é possível adicionar novas funções. A definição da função especifica o nome de uma nova função e a sequência de comandos que são executados quando a função é chamada. Aqui está um exemplo:

function imprimir_letras()
    println("O cravo brigou com a rosa")
    println("Debaixo de uma sacada.")
end

function é uma palavra-chave que indica a definição de função. O nome da função é imprimir_letras. As regras para nomes de funções são as mesmas dos nomes de variáveis: eles podem conter quase todos os caracteres Unicode (veja [caracteres]), mas o primeiro caracter não pode ser um número. Você não pode usar uma palavra-chave como nome de uma função, e evite ter uma variável e uma função com o mesmo nome.

Os parênteses vazios após o nome da função indicam que esta função não recebe nenhum argumento.

A primeira linha da definição da função é o cabeçalho; o restante é chamado de corpo. Finaliza-se o corpo com a palavra-chave end e pode conter qualquer número de comandos. Para facilitar a leitura, o corpo da função deve estar indentado.

As aspas devem ser "aspas retas" (""), geralmente localizadas abaixo do Esc no teclado. As "aspas encaracoladas" ou "aspas inglesas" (“”), como as que estão nesta frase, não são legais em Julia.

Se você definir uma função no modo interativo, o REPL indentará para informar que a definição não está finalizada:

julia> function imprimir_letras()
       println("O cravo brigou com a rosa")

Para terminar a função, deve-se inserir end.

A sintaxe para chamar a nova função é a mesma das funções internas:

julia> imprimir_letras()
O cravo brigou com a rosa
Debaixo de uma sacada.

Uma vez definida uma função, você pode usá-la dentro de outra função. Por exemplo, para repetir o refrão anterior, poderíamos escrever uma função chamada repetir_letras:

function repetir_letras()
    imprimir_letras()
    imprimir_letras()
end

E depois é só chamar repetir_letras:

julia> repetir_letras()
O cravo brigou com a rosa
Debaixo de uma sacada.
O cravo brigou com a rosa
Debaixo de uma sacada.

Mas não é bem assim que a música é.

Definições e usos

Reunindo os pedaços de código da seção anterior, o programa completo fica assim:

function imprimir_letras()
    println("O cravo brigou com a rosa")
    println("Debaixo de uma sacada.")
end

function repetir_letras()
    imprimir_letras()
    imprimir_letras()
end

repetir_letras()

Este programa contém duas definições de funções: imprimir_letras e repetir_letras. As definições de funções são executadas exatamente como outros comandos, e o resultado é a criação de objetos do tipo função. Os comandos dentro da função não são executados até que a função seja chamada, e a definição da função não gera saída.

Como você pode esperar, deve-se criar uma função antes de poder executá-la. Em outras palavras, a definição da função tem que ser executada antes de chamá-la.

Exercício 3-1

Mova a última linha deste programa para o topo, para que a chamada de função apareça antes das definições. Execute o programa e veja qual mensagem de erro você recebe.

Agora mova a chamada de função de volta para a parte inferior e mova a definição de imprimir_letras após a definição de repetir_letras. Ao executar este programa, o que acontece?

Fluxo de execução

Para garantir a definição de uma função antes de sua primeira chamada, é necessário conhecer a ordem dos comandos executados, conhecido como fluxo de execução.

A execução é feita sempre a partir do primeiro comando do programa. Os comandos são executados uma de cada vez, de cima para baixo.

As definições das funções não mudam o fluxo de execução do programa, mas lembre-se que os comandos dentro da função são executados somente quando a função é chamada.

Quando a função é chamada, é como um desvio no fluxo de execução. Em vez de ir para o comando seguinte, o fluxo salta para o corpo da função, executa os comandos lá e depois volta para continuar de onde parou.

Isso parece bastante simples, até você lembrar que uma função pode chamar outra. Enquanto estiver no meio de uma função, o programa pode ter a necessidade de executar os comandos em uma outra função. Logo, ao executar essa nova função, o programa pode precisar executar outra função!

Felizmente, Julia é bom em monitorar seus passos, portanto, toda vez que uma função é concluída, o programa retoma de onde parou na função que a chamou. Chegando ao final do programa, ele é encerrado.

Em resumo, quando você lê um programa, nem sempre deseja ler de cima para baixo. Às vezes, é mais lógico seguir o fluxo de execução.

Parâmetros e argumentos

Algumas das funções que vimos exigem argumentos. Por exemplo, quando você chama sin, um número é passado como argumento. Algumas funções usam mais de um argumento: parse necessita de dois, um tipo de número e uma string.

Dentro da função, os argumentos são atribuídos a variáveis denominadas parâmetros. Aqui está uma definição para uma função que exige um argumento:

function imprimir2vezes(bruno)
    println(bruno)
    println(bruno)
end

Esta função atribui o argumento a um parâmetro denominado bruno. Quando a função é chamada, imprime-se o valor do parâmetro (qualquer que seja) duas vezes.

Esta função funciona com qualquer valor que possa ser impresso.

julia> imprimir2vezes("Spam")
Spam
Spam
julia> imprimir2vezes(42)
42
42
julia> imprimir2vezes(π)
π
π

As mesmas regras de composição que se aplicam às funções embutidas também se aplicam às funções definidas pelo programador, portanto podemos usar qualquer tipo de expressão como argumento para imprimir2vezes:

julia> imprimir2vezes("Spam "^4)
Spam Spam Spam Spam
Spam Spam Spam Spam
julia> imprimir2vezes(cos(π))
-1.0
-1.0

O argumento é avaliado antes da chamada da função, de modo que nos exemplos as expressões "Spam "^4 e cos(π) são avaliadas apenas uma vez.

Também pode-se usar uma variável como argumento:

julia> ana = "Uma andorinha sozinha não faz verão."
"Uma andorinha sozinha não faz verão."
julia> imprimir2vezes(ana)
Uma andorinha sozinha não faz verão.
Uma andorinha sozinha não faz verão.

O nome da variável que passamos como argumento (ana) não tem nada a ver com o nome do parâmetro (bruno). Para a função imprimir2vezes, todos os parâmetros são chamados bruno, independentemente do nome da variável que passamos como argumento (neste caso, ana)

As variáveis e os parâmetros são locais

Ao criar uma variável dentro de uma função, ela é local, isto é, ela existe apenas dentro da função. Por exemplo:

function concat_imprimir2vezes(parte1, parte2)
    concat = parte1 * parte2
    imprimir2vezes(concat)
end

Esta função exige dois argumentos, concatena-os e imprime o resultado duas vezes. A seguir um exemplo que a usa:

julia> linha1 = "Lava outra, "
"Lava outra, "
julia> linha2 = "lava uma."
"lava uma."
julia> concat_imprimir2vezes(linha1, linha2)
Lava outra, lava uma.
Lava outra, lava uma.

Após o término de concat_imprimir2vezes, a variável concat é destruída. Se tentarmos imprimi-la, aparece uma exceção:

julia> println(concat)
ERROR: UndefVarError: concat not defined

Os parâmetros também são locais. Por exemplo, fora do imprimir2vezes, não existe o bruno.

Diagramas de Pilha

Para verificar quais variáveis podem ser usadas e onde, às vezes é prático desenhar um diagrama de pilha. Da mesma maneira dos diagramas de estado, os diagramas de pilha mostram o valor de cada variável, e mostram também a função à qual cada variável pertence.

Cada função é indicada por um quadro, que é representado por uma caixa com o nome de uma função ao lado e os parâmetros e as variáveis da respectiva função dentro dele. O diagrama de pilha do exemplo anterior é ilustrado em Stack diagram.

fig31
Figura 2. Stack diagram

Os quadros são dispostos em uma pilha que mostra qual função é chamada por outra, e assim por diante. Neste exemplo, imprimir2vezes foi chamada por concat_imprimir2vezes, e concat_imprimir2vezes foi chamada por Main, que é um nome especial para o quadro superior. Criando uma variável fora de qualquer função, ela pertence a Main.

Cada parâmetro recebe o mesmo valor que o seu argumento correspondente. Logo, parte1 tem o mesmo valor que linha1, da mesma forma que parte2 tem o mesmo valor que linha2, e bruno tem o mesmo valor que concat.

Em um caso de erro durante uma chamada de função, Julia imprime o nome da função, o nome da função que a chamou, e o nome da função que chamou por ela, e assim por diante até chegar no Main.

Por exemplo, se você tentar acessar concat de dentro de imprimir2vezes, você recebe um UndefVarError:

ERROR: UndefVarError: concat not defined
Stacktrace:
 [1] imprimir2vezes at ./REPL[1]:2 [inlined]
 [2] concat_imprimir2vezes(::String, ::String) at ./REPL[2]:3

Esta lista de funções é chamada de rastreamento de pilha, que informa em qual arquivo de programa ocorreu o erro, em qual linha e quais funções estavam sendo executadas no momento. Também indica a linha de código que causou o erro.

A ordem das funções no rastreamento de pilha é a ordem inversa dos quadros no diagrama de pilha. A função atualmente em execução fica no topo.

Funções produtivas e funções nulas

Algumas das funções que usamos, como as funções matemáticas que retornam resultados; por falta de um nome melhor, chamaremos de funções produtivas. As outras funções, como imprimir2vezes, que executam uma ação sem retornar um valor chamaremos de funções nulas.

Quando você chama uma função produtiva, quase sempre deseja-se fazer algo com o resultado; por exemplo, atribuí-lo a uma variável ou usá-lo como parte de uma expressão:

x = cos(radianos)
áurea = (sqrt(5) + 1) / 2

Ao chamar uma função no modo interativo, Julia exibe o seguinte resultado:

julia> sqrt(5)
2.23606797749979

Porém em um script, se chamar uma função produtiva por si só, o valor de retorno será perdido para sempre!

sqrt(5)

Output:

2.23606797749979

Este script calcula a raiz quadrada de 5, que não é armazenado e nem exibido o resultado, e assim, não é muito útil.

As funções nulas podem exibir algo na tela ou ter algum outro efeito, mas não retorna um valor. Se atribuir o resultado a uma variável, obterá um valor especial chamado nothing.

julia> resultado = imprimir2vezes("Bing")
Bing
Bing
julia> show(resultado)
nothing

Para imprimir o valor nothing, usa-se a função show que é similar a print mas que pode lidar com o valor nothing.

O valor nothing não é o mesmo que a string "nothing". Pois é um valor especial que tem seu próprio tipo:

julia> typeof(nothing)
Nothing

As funções que temos escrito até o momento são todas nulas. Começaremos a escrever funções produtivas em alguns capítulos.

Por que funções?

Pode não estar claro o motivo de fragmentar um programa em funções, mas existem várias razões:

  • Criar uma nova função dá a oportunidade de nomear uma série de comandos, o que facilita a leitura e a depuração do programa.

  • As funções podem reduzir o tamanho de um programa, eliminando a repetição do código. Mais tarde, no caso de alguma mudança, é só modificá-lo em um único lugar.

  • Dividir um programa longo em funções permite a depuração das partes, uma de cada vez, e depois reuni-las em um programa mais funcional.

  • Funções bem programadas são frequentemente úteis para muitos outros programas. Depois de escrever e depurar um, você pode reutilizá-la.

  • Em Julia, as funções podem melhorar consideravelmente o desempenho.

Depuração

Uma das habilidades mais significativas que você vai adquirir é a depuração. Ainda que possa ser frustrante, a depuração é uma das partes da programação mais intelectualmente rica, desafiadora e interessante.

De certa forma, a depuração é como um trabalho de detetive. Você é confrontado com pistas e precisa inferir os processos e eventos que levaram aos resultados encontrados.

A depuração também é como uma ciência experimental. Uma vez que você tem uma ideia do que está dando errado, modifique seu programa e tente novamente. Se a sua hipótese estiver correta, pode-se prever o resultado da modificação e aproximar-se de um programa funcional. Se a sua hipótese estava errada, inventa-se uma nova. Como Sherlock Holmes apontou,

Tendo eliminado o impossível, aquilo que resta, ainda que improvável, deve ser a verdade.

— A. Conan Doyle
O Signo dos Quatro

Algumas pessoas consideram que a programação e a depuração são a mesma coisa, já que a programação é o processo de depurar gradualmente um programa até que ele faça o que o programador deseja. A ideia é começar com um programa funcional e fazer pequenas mudanças, depurando-as à medida que avança.

Por exemplo, o Linux é um sistema operacional com milhões de linhas de código, mas começou como um programa simples que Linus Torvalds usava para examinar o chip Intel 80386. De acordo com Larry Greenfield, "um dos primeiros projetos de Linus era um programa que alternava entre imprimir "AAAA" e "BBBB". Este mais tarde evoluiu para Linux. ” (The Linux Users' Guide Versão Beta 1).

Glossário

função

Uma sequência nomeada de comandos que realiza alguma operação útil. As funções podem ou não nessitar de argumentos e podem ou não gerar um resultado.

definição de função

Um comando que cria uma nova função, e com especificação do seu nome, seus parâmetros e dos comandos que ela contém.

objeto do tipo função

Um valor criado por uma definição de função. O nome da função é uma variável que se refere a um objeto do tipo função.

cabeçalho

A primeira linha de uma definição de função.

corpo

A sequência de comandos dentro de uma definição de função.

parâmetro

Um nome usado dentro de uma função para se referir ao valor passado como argumento.

chamada de função

Um comando que executa uma função. Consiste no nome da função seguido de uma lista de argumentos entre parênteses.

argumento

Um valor fornecido a uma função quando a função é chamada. E este valor é atribuído ao parâmetro correspondente na função.

variável local

Uma variável definida dentro de uma função. Uma variável local só pode ser utilizada dentro de sua função.

valor de retorno

O resultado de uma função. Se uma chamada de função é utilizada como uma expressão, o valor de retorno é o valor da expressão.

função produtiva

Uma função que retorna um valor.

função nula

Uma função que sempre retorna nothing.

nothing

Um valor especial devolvido por funções nulas.

composição

Usar uma expressão como parte de uma expressão maior ou um comando como parte de um comando maior.

fluxo de execução

A ordem da execução dos comandos.

diagrama da pilha

Representação gráfica de uma pilha de funções, suas variáveis e os valores a que se referem.

quadro

Uma caixa em um diagrama de pilha que representa uma chamada de função, além de conter as variáveis e parâmetros locais da função.

rastreamento de pilha

Uma lista das funções que estão sendo executadas, mostrada quando ocorre uma exceção.

Exercícios

Dica

Esses exercícios devem ser realizados usando apenas os comandos e outros recursos aprendidos até o momento.

Exercício 3-2

Escreva uma função denominada alinhar_a_direita que recebe uma string denominada s como parâmetro e imprime a string com espaços suficientes à esquerda de modo que a última letra da string esteja na coluna 70 da exibição.

julia> alinhar_a_direita("trapalhões")
ERROR: UndefVarError: alinhar_a_direita not defined
Dica

Use concatenação e repetição de string. Além disso, Julia fornece uma função interna chamada length que retorna o comprimento de uma string, portanto o valor de length("trapalhões") é 10.

Exercício 3-3

Um objeto do tipo função é um valor que você pode associar a uma variável ou passar como argumento. Por exemplo, fazer2vezes é uma função que pega um objeto do tipo função como argumento e o chama duas vezes:

function fazer2vezes(f)
    f()
    f()
end

Veja um exemplo que usa fazer2vezes para chamar a função imprimir_spam duas vezes.

function imprimir_spam()
    println("spam")
end

fazer2vezes(imprimir_spam)
  1. Copie este exemplo em um script e teste-o.

  2. Modifique fazer2vezes para que ele receba dois argumentos, um objeto do tipo função e um valor, e chame a função duas vezes, passando o valor como argumento.

  3. Copie a definição de imprimir2vezes apresentada no início deste capítulo para o seu script.

  4. Use a versão modificada de fazer2vezes para chamar imprimir2vezes duas vezes, e passando "spam" como argumento.

  5. Defina uma nova função chamada fazer4vezes que recebe um objeto do tipo função e um valor e chama a função quatro vezes, passando o valor como parâmetro. Esta função deve ter apenas dois comandos no corpo dessa função, e não quatro.

Exercício 3-4
  1. Escreva uma função imprimir_grade que desenha uma grade da seguinte maneira:

    julia> imprimir_grade()
    + - - - - + - - - - +
    |         |         |
    |         |         |
    |         |         |
    |         |         |
    + - - - - + - - - - +
    |         |         |
    |         |         |
    |         |         |
    |         |         |
    + - - - - + - - - - +
  2. Escreva uma função que desenhe uma grade semelhante, com quatro linhas e quatro colunas.

Crédito: este exercício é baseado em um exercício de Oualline, Practical C Programming, Terceira Edição, O´Reilly Media, 1997.

Dica

Para mostrar mais de um valor em uma linha, você pode imprimir uma sequência de valores separados por vírgula:

println("+", "-")

A função print não avança para a linha seguinte:

print("+ ")
println("-")

A saída desses comandos é "+ -" na mesma linha. A saída do próximo comando é a impressão que começaria na seguinte linha.

4. Estudo de Caso: Design de Interface

Este capítulo apresenta um estudo de caso que demonstra o processo de design de funções que trabalham juntas.

Ele introduz o turtle graphics, um jeito de criar desenhos por meio de um programa. O pacote turtle graphics não está incluso na biblioteca padrão do Julia, portanto o módulo JuliaIntroBR tem que ser adicionado na sua configuração do Julia.

Turtles

Um módulo é um arquivo que contém uma coleção de funções relacionadas. O Julia fornece alguns módulos na sua biblioteca padrão. Mais funcionalidades podem ser adicionadas a partir de uma crescente coleção de pacotes (https://juliaobserver.com).

Pacotes podem ser instalados no REPL através do modo Pkg usando a tecla ].

(v1.0) pkg> add https://github.com/JuliaIntro/JuliaIntroBR.jl

Isso pode demorar um pouco.

Antes de podermos usar as funções de um módulo, temos que importá-lo usando a declaração using:

julia> using JuliaIntroBR

julia> 🐢 = Turtle()
Luxor.Turtle(0.0, 0.0, true, 0.0, (0.0, 0.0, 0.0))

O módulo JuliaIntroBR possui uma função chamada Turtle que cria um objeto Luxor.Turtle, que atribuímos a uma variável chamada 🐢 (\:turtle: TAB).

Logo após criarmos a tartaruga, podemos chamar uma função que a move ao redor de um desenho. Por exemplo, para mover a tartaruga para a frente:

@svg begin
    forward(🐢, 100)
end
fig41
Figura 3. Movendo a tartaruga para a frente

A palavra-chave @svg executa uma macro que desenha uma imagem SVG. Macros são uma parte importante, mas avançada, do Julia.

Os argumentos de forward são a tartaruga e a distância em pixels, portanto o tamanho real do movimento depende do seu monitor.

Outra função que nós podemos chamar com tartaruga como argumento é turn, para virar. O segundo argumento para turn é o ângulo em graus.

Além disso, cada tartaruga está segurando uma caneta, que pode estar tanto para baixo ou para cima; se a caneta está para baixo, a tartaruga deixa uma trilha quando ela se move. Movendo a tartaruga para a frente mostra a trilha deixada pela tartaruga. As funções penup e pendown significam respectivamente “caneta para cima” e “caneta para baixo”.

Para desenhar um ângulo reto, modifique a chamada de macro:

🐢 = Turtle()
@svg begin
    forward(🐢, 100)
    turn(🐢, -90)
    forward(🐢, 100)
end
Exercício 4-1

Agora modifique a macro para desenhar um quadrado. Não avance até que você faça isso funcionar!

Repetição Simples

Existe uma chance de que você escreveu algo como:

🐢 = Turtle()
@svg begin
    forward(🐢, 100)
    turn(🐢, -90)
    forward(🐢, 100)
    turn(🐢, -90)
    forward(🐢, 100)
    turn(🐢, -90)
    forward(🐢, 100)
end

Nós podemos fazer a mesma coisa de forma mais concisa com uma declaração for:

julia> for i in 1:4
          println("Olá!")
       end
Olá!
Olá!
Olá!
Olá!

Esse é o uso mais simples da declaração for; nós veremos mais usos mais tarde. Mas isso deve ser o suficiente para que você possa reescrever o seu programa que desenha um quadrado. Não avançe até que você o faça.

Aqui está uma declaração for que desenha um quadrado:

🐢 = Turtle()
@svg begin
    for i in 1:4
        forward(🐢, 100)
        turn(🐢, -90)
    end
end

A sintaxe de uma declaração for é similar a uma definição de função. Ela possui um cabeçalho e um corpo que termina com a palavra-chave end. O corpo pode conter qualquer número de declarações.

Uma declaração for também é chamada de laço, pois o fluxo de execução percorre o corpo e em seguida retorna até o topo em um ciclo. Nesse caso, ela percorre o corpo quatro vezes.

Essa versão é na verdade um pouco diferente do código que desenha um quadrado visto anteriormente, pois faz mais uma curva depois de desenhar o último lado do quadrado. A curva adicional leva mais tempo, mas simplifica o código se fizermos a mesma coisa toda vez que percorre o laço. Essa versão também faz a tartaruga retornar para sua posição inicial, de frente à direção inicial.

Exercícios

A seguir estão uma série de exercícios utilizando turtles. Elas tem o propósito de serem divertidas, mas tem um objetivo também. Enquanto você trabalha com elas, pense sobre qual é o objetivo.

Dica

A seção seguinte contém soluções para os exercícios, então não olhe até você terminar (ou pelo menos tentar).

Exercício 4-2

Escreva uma função chamada quadrado que recebe um parâmetro chamado t, que é uma tartaruga. Ela deve usar uma tartaruga para desenhar um quadrado.

Exercício 4-3

Escreva uma chamada de função que passa t como um argumento para quadrado, e em seguida execute o macro novamente.

Exercício 4-4

Adicione outro parâmetro, chamado com, em quadrado. Modifique o corpo para que o comprimento dos lados seja com, e então modifique a chamada de função para receber um segundo argumento. Execute a macro novamente. Teste com uma série de valores para com.

Exercício 4-5

Faça uma cópia de quadrado e mude o nome para polígono. Adicione outro parâmetro chamado n e modifique o corpo para que ele desenhe um polígono com \(n\) lados.

Dica

Os ângulos externos de um polígono regular de \(n\) lados são iguais a \(\frac{360}{n}\) graus.

Exercício 4-6

Escreva uma função chamada círculo que recebe uma tartaruga t, e raio r como parâmetros e que desenha uma figura próxima à um círculo através da chamada de polígono com um comprimento e número de lados apropriados. Teste sua função com uma série de valores de r.

Dica

Descubra a circunferência do círculo e garanta que com * n == circunferência.

Exercício 4-7

Faça uma versão mais geral de círculo chamada arco que recebe um parâmetro adicional ângulo, que determina qual fração de círculo desenhar. ângulo é uma medida em graus, então quando ângulo = 360, arco deve desenhar um círculo completo.

Encapsulamento

O primeiro exercício pede para que você coloque o seu código de desenhar quadrado em uma definição de função, e que em seguida você chame essa função utilizando tartaruga como parâmetro. Aqui está a solução:

function quadrado(t)
    for i in 1:4
        forward(t, 100)
        turn(t, -90)
    end
end
🐢 = Turtle()
@svg begin
    square(🐢)
end

As declarações forward e turn são indentadas duas vezes para mostrar que elas estão dentro do laço for, que está dentro da definição da função.

Dentro da função, t refere-se à mesma tartaruga 🐢, então turn(t, -90) tem o mesmo efeito que turn(🐢, -90). Neste caso, por que não chamar o parâmetro +🐢 ? A ideia é que t pode ser qualquer tartaruga, não somente 🐢, então você pode criar uma segunda tartaruga e passá-la como argumento para quadrado.

🐫 = Turtle()
@svg begin
    square(🐫)
end

Envolver um pedaço de código em uma função é chamado de encapsulamento. Um dos benefícios do encapsulamento é que ele anexa um nome ao código, que serve como uma forma de documentação. Outra vantagem é que se você está re-utilizando o código, é mais conciso chamar a função duas vezes do que copiar e colar o corpo!

Generalização

O próximo passo é adicionar com aos parâmetros de quadrado. Aqui está a solução:

function quadrado(t, com)
    for i in 1:4
        forward(t, com)
        turn(t, -90)
    end
end
🐢 = Turtle()
@svg begin
    square(🐢, 100)
end

Adicionar um parâmetro a uma função é chamado de generalização pois faz com que a função seja mais abrangente: na versão anterior, o quadrado sempre tem o mesmo tamanho; nesta versão ele pode ter qualquer tamanho.

O próximo passo também é uma generalização. Ao invés de desenhar quadrados, polígono desenha polígonos regulares com qualquer número de lados. Aqui está a solução:

function polígono(t, n, com)
    ângulo = 360 / n
    for i in 1:n
        forward(t, com)
        turn(t, -ângulo)
    end
end
🐢 = Turtle()
@svg begin
    polígono(🐢, 7, 70)
end

Este exemplo desenha um heptágono de lado medindo 70.

Design de Interface

O próximo passo é escrever círculo, que recebe um raio r como parâmetro. Aqui está uma solução simples que usa polígono para desenhar um polígono de 50 lados:

function círculo(t, r)
    circunferência = 2 * π * r
    n = 50
    com = circunferência / n
    polígono(t, n, com)
end

A primeira linha computa a circunferência de um círculo com raio \(r\) usando a fórmula \(2 \pi r\). n é o número de segmentos de linha usados na nossa aproximação de um círculo, e com é o comprimento de cada segmento. Portanto, polígono desenha um polígono de 50 lados que se aproxima um círculo de raio r.

Uma limitação dessa solução é que n é uma constante, o que significa que para círculos bem grandes, os segmentos de linha são muito longos, e para círculos pequenos, nós gastamos tempo desenhando segmentos bem pequenos. Uma solução seria generalizar a função para que ela receba n como parâmetro. Isso daria ao usuário (qualquer um que chame círculo) mais controle, mas a interface seria menos limpa.

A interface de uma função é um resumo de como ela deve ser usada: quais são os parâmetros? O que a função faz? E qual o seu valor de retorno? Uma interface é “limpa” se permite àquele que chamou a função fazer tudo o que ele quer sem precisar lidar com detalhes desnecessários.

Neste exemplo, r pertence à interface pois especifica o círculo a ser desenhado. n é menos apropriada pois diz respeito aos detalhes de como o círculo deve ser renderizado.

Em vez de bagunçar a interface, é melhor escolher um valor apropriado de n dependendo de circunferência:

function círculo(t, r)
    circunferência = 2 * π * r
    n = trunc(circunferência / 3) + 3
    com = circunferência / n
    polígono(t, n, com)
end

Agora o número de segmentos é um inteiro ao redor de circunferência/3, então o comprimento de cada segmento é aproximadamente 3, que é pequeno o suficiente para que os círculos fiquem bons, mas grande o suficiente para ser eficaz, e aceitável para qualquer tamanho de círculo.

Adicionar 3 a n garante que o polígono tenha no mínimo 3 lados.

Reestruturação

Quando escrevemos círculo, pudemos reutilizar polígono pois um polígono com vários lados é uma boa aproximação de um círculo. Mas arco não é igualmente cooperativo; não podemos usar polígono ou círculo para desenhar um arco.

Uma alternativa é começar com uma cópia de polígono e transformá-la em arco. O resultado pode parecer algo como:

function arco(t, r, ângulo)
    com_arco = 2 * π * r * ângulo / 360
    n = trunc(com_arco / 3) + 1
    tam_passo = com_arco / n
    ang_passo = ângulo / n
    for i in 1:n
        forward(t, tam_passo)
        turn(t, -ang_passo)
    end
end

A segunda metade dessa função parece-se com polígono, mas nós não podemos reusar polígono sem mudar a interface. Nós poderíamos generalizar polígono para receber ângulo como terceiro argumento, mas então polígono não seria mais um nome apropriado! Ao invés disso, chamaremos a função mais geral polilinha:

function polilinha(t, n, com, ângulo)
    for i in 1:n
        forward(t, com)
        turn(t, -ângulo)
    end
end

Agora nós podemos reescrever polígono e arco para usar polilinha:

function polígono(t, n, com)
    ângulo = 360 / n
    polilinha(t, n, com, ângulo)
end

function arco(t, r, ângulo)
    com_arco = 2 * π * r * ângulo / 360
    n = trunc(com_arco / 3) + 1
    com_passo = com_arco / n
    ang_passo = ângulo / n
    polilinha(t, n, com_passo, ang_passo)
end

Finalmente, nós podemos reescrever círculo para usar arco:

function círculo(t, r)
    arco(t, r, 360)
end

Este processo de reorganização de um programa para melhorar interface e facilitar o reuso de código é chamado de refatoração. Neste caso, nós percebemos que havia código similar em arco e polígono, então nós o “fatoramos” para dentro de polilinha.

Se nós tivéssemos planejado com antecedência, nós poderíamos ter escrito polilinha primeiro e evitado a refatoração, mas você frequentemente não sabe o suficiente no começo de um projeto para planejar todas as interfaces. A partir do momento em que você começa a programar, você passa a entender o problema melhor. Às vezes refatoração é um sinal de que você aprendeu alguma coisa.

Um Plano de Desenvolvimento

Um plano de desenvolvimento é um processo para escrever programas. O processo que usamos nesse estudo de caso é “encapsulamento e generalização”. Os passos desse processo são:

  1. Comece escrevendo um pequeno programa sem definições de funções.

  2. Uma vez que você fez com que o seu programa funcione, identifique um pedaço coerente dele, encapsule-o em uma função e dê a ela um nome.

  3. Generalize a função adicionando parâmetros apropriados.

  4. Repita os passos 1-3 até que você tenha um conjunto de programas funcionais. Copie e cole o código para evitar redigi-los (e redepurá-los).

  5. Busque por oportunidades de melhora no programa através da refatoração. Por exemplo, se você tem um código similar em vários lugares, considere fatorá-lo em uma função geral apropriada.

Esse processo tem algumas desvantagens-nós veremos as alternativas mais tarde-mas pode ser útil se você não sabe previamente como dividir o programa em funções. Essa abordagem permite que você planeje conforme você vai projetando.

Docstring

Uma docstring é uma string que vem antes de uma função e descreve sua interface (“doc” refere-se a “documentação”). Aqui está um exemplo:

"""
polilinha(t, n, com, ângulo)

Desenha n segmentos de linha dado o comprimento
e o ângulo (em graus) entre eles.  t é uma tartaruga.
"""
function polilinha(t, n, com, ângulo)
    for i in 1:n
        forward(t, com)
        turn(t, -ângulo)
    end
end

A documentação pode ser acessada no REPL ou em um notebook digitando ? seguido pelo nome de uma função ou macro, e apertando ENTER;

help?> polilinha
search:

  polilinha(t, n, com, ângulo)

  Desenha n segmentos de linha dado o comprimento e o ângulo (em graus) entre eles. t é uma tartaruga.

Docstrings são comumente strings envolvidas por três aspas, também conhecidas por strings multi-linha, pois as três aspas permitem que a string abranja mais de uma linha.

Uma docstring contém a informação essencial que alguém precisaria para usar essa função. Ela explica concisamente o que a função faz (sem entrar em detalhes de como ela faz). Ela explica que efeito cada parâmetro tem na execução da função e qual tipo cada parâmetro deve ser (se não é óbvio).

Dica

Escrever esse tipo de documentação é uma parte importante do design de interface. Uma interface bem projetada deve ser simples de explicar; se você encontra dificuldade em explicar uma de sua funções, talvez sua interface possa ser melhorada.

Depuração

Uma interface é como um contrato entre a função e quem a chama. Quem chama concorda em fornecer certos parâmetros e a função concorda em fazer um certo trabalho.

Por exemplo, polilinha requer quatro argumentos: t tem que ser uma tartaruga; n tem que ser um inteiro; com deve ser um número positivo; e ângulo tem que ser um número, que assume-se ser uma medida em graus.

Esses requerimentos são chamados de precondições pois eles deveriam ser verdadeiros antes que a função execute. Inversamente, condições no final da função são chamadas de pós-condições. Pós-condições incluem o efeito desejado da função (como desenhar segmentos de linha) e qualquer efeito colateral (como mover a tartaruga ou fazer outra mudança).

precondições são de responsabilidade de quem chama a função. Se quem chama viola uma precondição (propriamente documentada!) e a função não funciona adequadamente, o bug está em quem chamou, e não na função.

Se as precondições são satisfeitas e as pós-condições não, então o bug está na função. Se as suas pré- e pós-condições forem claras, elas podem ajudar na hora de depurar.

Glossário

módulo

Um arquivo que contém uma coleção de funções e outras definições relacionadas.

pacote

Uma biblioteca externa com funcionalidade adicional.

declaração using

Uma declaração que lê um arquivo módulo e cria um objeto módulo.

laço

Uma parte do programa que é executada repetidamente.

encapsulamento

O processo de transformar uma sequência de comandos em uma definição de função.

generalização

O processo de substituir algo desnecessariamente específico (como um número) por algo mais adequadamente irrestrito (como uma variável ou parâmetro).

interface

Uma descrição de como usar uma função, incluindo o nome, as descrições dos argumentos e o valor de retorno.

refatoração

O processo de modificar um programa funcional para melhorar a interface da função e outras qualidades do código.

plano de desenvolvimento

Um processo para escrever programas.

docstring

Uma string que aparece no topo de uma definição de função para documentar a interface da função.

precondição

Um requerimento que deve ser satisfeito por quem chama antes da função iniciar.

pós-condição

Um requerimento que deve ser satisfeito pela função antes de acabar.

Exercícios

Exercício 4-8

Digite o código deste capítulo em um notebook.

  1. Desenhe um diagrama de pilha que mostra o fluxo de execuções de círculo(🐢, raio). Você pode contar no dedo ou adicionar declarações de impressão no código.

  2. A versão de arco em [refatoração] não é muito precisa dado que a aproximação linear do círculo está sempre fora do verdadeiro círculo. Como resultado, a tartaruga acaba alguns pixels depois do destino correto. Minha solução mostra uma maneira de reduzir o efeito desse erro. Leia o código e veja se faz sentido para você. Se você desenhar o diagrama, você poderá ver como ela funciona.

"""
arco(t, r, ângulo)

Desenha um arco dado o raio e ângulo:

    t: tartaruga
    r: raio
    ângulo: ângulo feito pelo arco, em graus
"""
function arco(t, r, ângulo)
    com_arco = 2 * π * r * abs(ângulo) / 360
    n = trunc(com_arco / 4) + 3
    com_passo = com_arco / n
    ang_passo = ângulo / n

    # fazendo uma leve curva para a esquerda antes de iniciar
    # reduz o erro causado pela aproximação linear do arco
    turn(t, -ang_passo/2)
    polilinha(t, n, com_passo, ang_passo)
    turn(t, ang_passo/2)
end
Exercício 4-9

Escreva um conjunto geral de funções apropriadas que podem desenhar flores como em Flores de Tartaruga.

fig42
Figura 4. Flores de Tartaruga
Exercício 4-10

Escreva um conjunto geral de funções apropriadas que podem desenhar formas como as de Tortas de Tartaruga.

fig43
Figura 5. Tortas de Tartaruga
Exercício 4-11

As letras do alfabeto podem ser construídas a partir de um número moderado de elementos básicos, como linhas verticais, horizontais e algumas curvas. Projete um alfabeto que pode ser desenhado com o menor número de elementos básicos e em seguida escreva funções que desenhem letras.

Você deve escrever uma função para cada letra, com nomes desenha_a, desenha_b, etc., e coloque suas funções em um arquivo chamado letras.jl.

Exercício 4-12

Leia sobre espirais em https://pt.wikipedia.org/wiki/Espiral; em seguida escreva um programa que desenha uma espiral de Arquimedes como em Espiral de Arquimedes.

fig44
Figura 6. Espiral de Arquimedes

5. Condicionais e Recursão

O principal tópico deste capítulo é o comando if, que executa diferentes códigos dependendo do estado do programa. Mas antes gostaríamos de introduzir dois novos operadores: divisão inteira e módulo.

Divisão Inteira e Módulo

O operador de divisão inteira, ÷ (\div TAB), divide dois números e arredonda pra baixo para um inteiro. Por exemplo, suponha que o tempo de duração de um filme seja de 105 minutos. Talvez você queira saber o quanto isso equivale em horas. A divisão convencional retorna um número em ponto-flutuante:

julia> minutos = 105
105
julia> minutos / 60
1.75

Porém, normalmente não escrevemos horas com pontos decimais. A divisão inteira retorna um número inteiro de horas, arredondando para baixo:

julia> horas = minutos ÷ 60
1

Para obter o resto, você pode subtrair uma hora em minutos:

julia> resto = minutos - horas * 60
45

Uma alternativa é usar o operador módulo, %, que divide dois números e retorna o resto.

julia> resto = minutos % 60
45
Dica

O operador módulo é mais útil do que parece. Por exemplo, você pode verificar se um número é divisível por outro—se x % y é zero, então x é divisível por y.

Além disso, você pode extrair o dígito ou dígitos mais à direita de um número. Por exemplo, x % 10 gera o dígito mais à direita de um inteiro x (na base 10). Da mesma forma x % 100 produz os dois últimos dígitos.

Expressões Booleanas

Uma expressão booleana é uma expressão que é ou verdadeira ou falsa. Os seguintes exemplos usam o operador ==, que compara dois operandos e gera true se forem iguais e false--do inglês, verdadeiro e falso, respectivamente.

julia> 5 == 5
true
julia> 5 == 6
false

true e false são valores especiais que pertencem ao tipo Bool; esses valores não são strings:

julia> typeof(true)
Bool
julia> typeof(false)
Bool

O operador == é um dos operadores de relação; os outros são:

      x != y               # x não é igual a y
      x ≠ y                # (\ne TAB)
      x > y                # x é maior que y
      x < y                # x é menor que y
      x >= y               # x é maior ou igual a y
      x ≥ y                # (\ge TAB)
      x <= y               # x é menor ou igual a y
      x ≤ y                # (\le TAB)
Atenção

Embora essas operações provavelmente possam ser familiares à você, os símbolos em Julia são diferentes dos símbolos matemáticos. Um erro comum é usar um único sinal de igual (=) em vez de um duplo sinal de igual (==). Lembre-se de que = é um operador de atribuição e == é um operador relacional. Não existe =< ou =>.

Operadores Lógicos

Existem três operadores lógicos: && (e), || (ou) e ! (não). A semântica (significado) destes operadores são similiares aos seus significados em Português. Por exemplo, x > 0 && x < 10 é verdadeiro se e somente se x é maior que 0 e menor que 10.

n % 2 == 0 || n % 3 == 0 é verdadeiro se uma ou ambas condições são verdadeiras, isto é, se o número é divisível por 2 ou por 3.

Tanto && quanto || associam à direita, mas && tem maior precedência que ||.

Por fim, o operador ! nega uma expressão booleana, então !(x > y) é verdadeira se x > y é falsa, isto é, se x é menor ou igual a y.

Execução Condicional

Para escrever programas úteis, quase sempre precisamos verificar as condições e alterar o comportamento de acordo com o programa. Comandos condicionais nos fornecem essa habilidade. A forma mais simples é o comando if (da conjunção se em Inglês).

if x > 0
    println("x é positivo")
end

A expressão booleana depois de if é chamada de condição. Se é verdadeira, então o comando indentado é executado. Se não, nada acontece.

O comando if possui a mesma estrutura das definições de funções: Um cabeçalho seguido de um corpo terminado com a palavra-chave end. Comandos como esse são chamadas de comandos compostos.

Não há limites para o número de comandos que podem aparecer no corpo. Ocasionalmente, é útil ter um corpo sem comandos (geralmente como detentor de um lugar para o código que você ainda não escreveu).

if x < 0
    # LEMBRETE: precisa lidar com valores negativos!
end

Execuções Alternativas

Uma segunda maneira de usar o comando if é através da "execução alternativa", que oferece duas possibilidades e a condição determina qual delas deverá ser executada. A sintaxe é a seguinte:

if x % 2 == 0
    println("x é par")
else
    println("x é ímpar")
end

Se o resto da divisão de x por 2 é 0, então sabemos que x é par, e o programa irá exibir a mensagem apropriada. Se a condição for falsa, o segundo conjunto de comandos será executado. Desde que a condição seja verdadeira ou falsa, exatamente uma das alternativas irá ser executada. Essas alternativas são chamadas de ramos (branches em inglês), porque são ramos de um fluxo de execução.

Condicionais Encadeadas

Algumas vezes há mais do que duas possibilidades e precisamos de mais que dois ramos. Uma maneira de expressar um comando como esse é através de condicionais encadeadas:

if x < y
    println("x é menor que y")
elseif x > y
    println("x é maior que y")
else
    println("x e y são iguais")
end

Novamente, exatamente um dos ramos será executado. Não há limites para o número de comandos elseif. Se existir uma cláusula else, essa deve estar no final, mas não precisa haver uma.

if escolha == "a"
    desenhe_a()
elseif escolha == "b"
    desenhe_b()
elseif escolha == "c"
    desenhe_c()
end

Cada condição é checada em ordem. Se a primeira for falsa, a próxima é checada e assim por diante. Se uma delas é verdadeira, o ramo correspondente é executado e o comando é encerrado. Se mais de uma condição é verdadeira, apenas o primeiro ramo verdadeiro é executado.

Condicionais Aninhadas

Uma condicional também pode ser aninhada com outra. O exemplo da seção anterior poderia ter sido escrito da seguinte maneira:

if x == y
    println("x e y são iguais")
else
    if x < y
        println("x é menor que y")
    else
        println("x é maior que y")
    end
end

O condicional externo contém dois ramos. O primeiro ramo contém um comando simples. O segundo ramo contém outro condicional if, que possui dois ramos inseridos nele. Esses dois ramos são comandos simples, embora também possam ter sido declarações condicionais.

Embora a indentação não obrigatória das declarações torne a estrutura aparente, as condicionais aninhadas tornam-se difíceis de ler muito rapidamente. Uma boa ideia é evitá-las quando puder.

Operadores lógicos geralmente produzem uma maneira de simplificar instruções condicionais aninhadas. Por exemplo, podemos reescrever o seguinte código usando uma única condicional:

if 0 < x
    if x < 10
        println("x é um número positivo de um dígito.")
    end
end

O comando print executa somente se for verdadeira nas duas condições, para que possamos obter o mesmo efeito com operador &&:

if 0 < x && x < 10
    println("x é um número positivo de um dígito.")
end

Para esse tipo de condição, o Julia fornece uma sintaxe mais concisa:

if 0 < x < 10
    println("x é um número positivo de um dígito.")
end

Recursão

É possível fazer com que uma função chame outra; também é possível uma função chamar a si mesma. Pode não parecer óbvio por que isso é uma coisa boa, mas acaba sendo uma das coisas mais mágicas que um programa pode fazer. Por exemplo, observe a seguinte função:

function contagem_regressiva(n)
    if n ≤ 0
        println("Feliz Ano Novo!")
    else
        print(n, " ")
        contagem_regressiva(n-1)
    end
end

Se n é 0 ou negativo, será exibido a frase "Feliz Ano Novo!". Caso contrário, a função exibe n e chama uma função chamada contagem_regressiva—ela mesma— passando n-1 como argumento.

O que acontece se chamarmos uma função como essa?

julia> contagem_regressiva(3)
3 2 1 Feliz Ano Novo!

A execução de contagem_regressiva começa com n = 3, e como n é maior que 0, terá como saída o valor 3, e depois executa a si mesma…​

 A execução de contagem_regressiva começa com n = 2, e como n é maior que 0,
  terá como saída o valor 2, e depois executa a si mesma …​

  A execução de contagem_regressiva começa com n = 1, e como n é maior que 0,
   terá como saída o valor 1, e depois executa a si mesma …​

   A execução de contagem_regressiva começa com n = 0, e como n não é maior que
    0, terá como saída uma frase, "Feliz Ano Novo!" e depois retorna.

  A contagem regressiva que obteve n = 1 retorna.

 A contagem regressiva que obteve n = 2 retorna.

A contagem regressiva que obteve n = 3 retorna.

E então você retornará para Main.

Uma função que chama a si mesma é dita recursiva; o processo de execução desta função é chamada de recursão.

Um outro exemplo é que podemos escrever uma função que imprime uma string \(n\) vezes.

function imprima_n(s, n)
    if n ≤ 0
        return
    end
    println(s)
    imprima_n(s, n-1)
end

Se n <= 0 o comando return sai da função. O fluxo de execução retorna imediatamente para quem a chamou e as linhas restantes da função não são executadas.

O restante da função é similar a contagem_regressiva: Ela exibirá s e chamará a si mesma para exibir s \(n-1\) várias vezes. Portanto, o número de linhas de saída é \(1 + (n - 1)\), o que soma \(n\).

Para exemplos simples como esse, provavelmente é mais fácil usar um laço for. Veremos exemplos em que são difíceis de escrever com um laço for e fáceis de escrever com recursão; portanto, é uma boa ideia começar cedo.

Diagramas de Pilhas para Funções Recursivas

Em Diagramas de Pilha, usamos um diagrama de pilha para representar o estado de um programa durante uma chamada de função. O mesmo tipo de diagrama pode ajudar a interpretar uma função recursiva.

Sempre que uma função é chamada, o Julia cria um quadro para conter os parâmetros e as variáveis locais da função. Para uma função recursiva, pode haver mais de um quadro na pilha ao mesmo tempo.

fig51
Figura 7. Diagrama de Pilha

Diagrama de Pilha mostra um diagrama de pilha para contagem_regressiva chamada com n = 3.

Como sempre, o topo da pilha é o quadro para Main. Ele está vazio porque não criamos nenhuma variável em Main ou nem passamos algum argumento para ela.

Os quatro quadros de contagem_regressiva contém valores diferentes para o parâmetro n. A parte inferior da pilha, onde n = 0, é chamada de caso base. Ele não faz uma chamada recursiva, portanto não há mais quadros.

Exercício 5-1

Como exercício, desenhe um diagrama de pilha para imprima_n chamado com s = "Olá" e n = 2. Depois, escreva uma função chamada faça_n que pega um objeto de função e um número, n, como argumento, e que chama a função dada \(n\) vezes.

Recursão Infinita

Se uma recursão nunca atinge o caso base, ela continua fazendo chamadas recursivas para sempre e o programa nunca termina. Isso é conhecido como recursão infinita, e geralmente isso não é uma boa ideia. À seguir, um pequeno programa com uma recursão infinita:

function recursão()
    recursão()
end

Na maioria dos ambientes de programação, um programa com recursão infinita realmente não é executado para sempre. O Julia exibe uma mensagem de erro quando a profundidade máxima de recursão é atingida:

julia> recursão()
ERROR: StackOverflowError:
Stacktrace:
 [1] recursão() at ./REPL[1]:2 (repeats 80000 times)

Esse rastreamento de pilha é um pouco maior do que vimos no capítulo anterior. Quando o erro ocorre, existem 80000 quadros de recursão na pilha!

Se você encontrar uma recursão infinita por acidente, revise a sua função para confirmar se há um caso base que não faz uma chamada recursiva. E se houver, verifique que você está garantindo o alcance do caso base.

Entradas do Teclado

Os programas que escrevemos até agora não aceitam nenhuma entrada do usuário. Eles apenas fazem a mesma coisa toda hora.

O Julia fornece uma função interna chamada readline que interrompe o programa e aguarda o usuário digitar algo. Quando o usuário pressiona RETURN ou ENTER, o programa é retomado e readline retorna o que o usuário digitou como uma sequência de caracteres.

julia> texto = readline()
O que você está esperando?
"O que você está esperando?"

Antes de receber informações do usuário, é uma boa ideia imprimir um prompt informando ao usuário o que digitar:

julia> print("Quem és tu? "); readline()
Quem és tu? Sou Hermanoteu da Pentescopéia, irmão da Micalatéia.
"Sou Hermanoteu da Pentescopéia, irmão da Micalatéia."

Um ponto e vírgula ; permite colocar múltiplos comandos na mesma linha. No REPL apenas o último comando retornará seu valor.

Se você espera que o usuário digite um número inteiro, tente converter o valor de retorno para Int64:

julia> println("Qual é a velocidade de voo de uma andorinha sem carga?"); velocidade = readline()
Qual é a velocidade de voo de uma andorinha sem carga?
42
"42"
julia> parse(Int64, velocidade)
42

Mas se o usuário digitar algo diferente de uma sequência de dígitos, você receberá um erro:

julia> println("Qual é a velocidade de voo de uma andorinha sem carga?"); velocidade = readline()
Qual é a velocidade de voo de uma andorinha sem carga?
Como assim, uma andorinha africana ou européia?
"Como assim, uma andorinha africana ou européia?"
julia> parse(Int64, velocidade)
ERROR: ArgumentError: invalid base 10 digit 'C' in "Como assim, uma andorinha africana ou européia?"
[...]

Veremos como lidar com esse tipo de erro posteriormente.

Depuração

Quando um erro de sintaxe ou de tempo de execução ocorrer, a mensagem de erro contém muitas informações, mas ela pode ser avassaladora. As partes mais úteis são geralmente:

  • Que tipo de erro foi, e

  • Onde ocorreu.

Os erros de sintaxe geralmente são fáceis de encontrar, mas existem algumas ressalvas. Em geral, as mensagens de erro indicam onde o problema foi descoberto, mas o verdadeiro erro pode estar antes no código, às vezes em uma linha anterior.

O mesmo vale para erros de tempo de execução. Suponha que você esteja tentando calcular uma taxa de sinal/ruído em decibéis. A fórmula é

\[\begin{equation} {SNR_{\mathrm{db}} = 10 \log_{10} \frac{P_{\mathrm{sinal}}}{P_{\mathrm{ruido}}}\ .} \end{equation}\]

No Julia você pode escrever desta forma:

potência_do_sinal = 9
potência_do_ruido = 10
razão = potência_do_sinal ÷ potência_do_ruido
decibéis = 10 * log10(razão)
print(decibéis)

E você obtém:

-Inf

Com certeza não era um resultado que você estava esperando.

Para encontrar o erro, pode ser útil imprimir o valor da razão, que acaba sendo 0. O problema está na linha 3, que usa a divisão de piso em vez da divisão do ponto flutuante.

Atenção

Você deve ler atentamente as mensagens de erro, mas não presuma que tudo o que elas dizem está correto.

Glossário

divisão inteira

Um operador, denotado ÷, que divide dois números e arredonda para baixo (em direção ao infinito negativo) para um número inteiro.

operador módulo

Um operador, indicado com um sinal de porcentagem (%), que trabalha com números inteiros e retorna o restante quando um número é dividido por outro.

expressão booleana

Uma expressão cujo os valores são ou true ou false.

operador relacional

Um dos operadores que compara operandos: ==, (!=), >, <, (>=), e (<=).

operador lógico

Um dos operadores que combina expressões booleanas: && (e), || (ou), e ! (não).

comando condicional

Um comando que controla o fluxo de execução dependendo de alguma condição.

condição

A expressão boleana em um comando condicional que determina qual ramo executará.

comando composto

Um comando que consiste em um cabeçalho e um corpo. O corpo é terminado com a palavra-chave end.

ramos

Uma das sequências alternativas de comandos em um comando condicional.

comando encadeado

Um comando condicional com uma série de ramos alternativos.

condicional aninhada

Um comando condicional que aparece em um dos ramos de outro comando condicional.

comando de retorno

Um comando que faz com que uma função pare de executar imediatamente e retorne para quem a chamou.

recursão

O processo de chamar a função que está sendo executada.

caso base

Uma ramo condicional de uma função recursiva que não faz um chamado recursiva.

recursão infinita

Uma recursão que não tem um caso base ou que nunca atinge ela. Eventualmente, uma recursão infinita causa um erro de tempo de execução.

Exercícios

Exercício 5-2

A função time retorna o Horário do Meridiano de Greenwich em segundos desde "a época", que é um horário arbitrário usado como ponto de referência. Nos sistemas UNIX, a época é 1 de janeiro de 1970.

julia> time()
1.595552598838898e9

Escreva um script que leia a hora atual e a converta para uma hora do dia em horas, minutos e segundos, mais o número de dias desde a época.

Exercício 5-3

O Último Teorema de Fermat diz que não existem inteiros positivos \(a\), \(b\), e \(c\) tais que

\[\begin{equation} {a^n + b^n = c^n} \end{equation}\]

para qualquer valor de \(n\) maior que 2.

  1. Escreva uma função chamada fermat que coleta quatro parâmetros — a, b, c e n — e verifica se o Teorema de Fermat é valido. Se n é maior que 2 e a^n + b^n == c^n o programa deve imprimir, "Oloco, Fermat estava errado!" caso contrário o programa deve imprimir, "Não, isso não funciona."

  2. Escreva uma função que solicite ao usuário que insira valores para a, b, c e n, converta-os em números inteiros e use fermat para verificar se eles violam o teorema de Fermat.

Exercise 5-4

Se você receber três gravetos, poderá ou não ser capaz de organizá-los em um triângulo. Por exemplo, se um dos gravetos tiver 12 centímetros de comprimento e os outros dois um centímetro, você não conseguirá formar um triângulo. Para três comprimentos dados, há um teste simples para verificar se é possível formar um triângulo:

Dica

Se qualquer um dos três comprimentos for maior que a soma dos outros dois, não será possível formar um triângulo. Caso contrário, você pode. (Se a soma de dois comprimentos for igual ao terceiro, eles formarão o que é chamado de triângulo "degenerado".)

  1. Escreva uma função chamada é_triângulo que aceite três números inteiros como argumentos e imprima “Sim” ou “Não”, dependendo da possibilidade de formar ou não um triângulo a partir de gravetos com os comprimentos especificados.

  2. Escreva uma função que solicite ao usuário a inserção de três comprimentos de gravetos, os converta em números inteiros e use é_triângulo para verificar se os gravetos com os comprimentos especificados podem formar um triângulo.

Exercício 5-5

Qual é a saída do seguinte programa? Desenhe um diagrama de pilha que mostre o estado do programa quando ele imprimir o resultado.

function recursão(n, s)
    if n == 0
        println(s)
    else
        recursão(n-1, n+s)
    end
end

recursão(3, 0)
  1. O que aconteceria se você chamasse essa função assim: recursão(-1, 0)?

  2. Escreva uma docstring que explique tudo o que alguém precisaria saber para usar esta função (e nada mais).

Os exercícios a seguir usam o módulo JuliaIntroBR, descrito no Estudo de Caso: Design de Interface:

Exercício 5-6

Leia a função a seguir e veja se você consegue descobrir o que ela faz (veja os exemplos em Estudo de Caso: Design de Interface). Em seguida, execute-a e veja se você acertou.

function desenhe(t, comprimento, n)
    if n == 0
        return
    end
    ângulo = 50
    forward(t, comprimento*n)
    turn(t, -ângulo)
    draw(t, comprimento, n-1)
    turn(t, 2*angle)
    draw(t, comprimento, n-1)
    turn(t, -ângulo)
    forward(t, -comprimento*n)
end
Exercício 5-7
fig52
Figura 8. Curva de Koch

A curva de Koch é um fractal que parece com o da Curva de Koch. Para desenhar uma curva de Koch com comprimento \(x\), tudo que você precisa fazer é:

  1. Desenhar uma curva de Koch com comprimento \(\frac{x}{3}\).

  2. Girar 60° para esquerda

  3. Desenhar uma curva de Koch com comprimento \(\frac{x}{3}\).

  4. Girar 120° para direita.

  5. Desenhar uma curva de Koch com comprimento \(\frac{x}{3}\).

  6. Girar 60° para esquerda.

  7. Desenhar uma curva de Koch com comprimento \(\frac{x}{3}\).

A exceção é se \(x\) for menor que 3: neste caso, você só desenha uma linha reta de comprimento \(x\).

  1. Escreva uma função chamada koch que receba um turtle e um comprimento como parâmetros e que use o turtle para desenhar uma curva de Koch com o comprimento especificado.

  2. Escreva uma função chamada + floco_de_neve + que desenhe três curvas de Koch para fazer o contorno de um floco de neve.

  3. A curva de Koch pode ser generalizada de várias maneiras. Veja https://en.wikipedia.org/wiki/Koch_snowflake para exemplos e implemente o seu favorito.

6. Funções Produtivas

Muitas das funções de Julia que usamos, como as funções matemáticas, produzem valores de retorno. Contudo, as funções que escrevemos são todas nulas: têm um efeito, como imprimir um valor ou mover uma tartaruga, mas retornam nothing. Neste capítulo você aprenderá a elaborar funções produtivas.

Valores de Retorno

A chamada de uma função gera um valor de retorno, que geralmente atribuímos a uma variável ou utilizamos como parte de uma expressão.

e = exp(1.0)
altura = raio * sin(radianos)

As funções escritas até o momento são nulas, o que significa que elas não têm valor de retorno; mais precisamente, seu valor de retorno é nothing. Neste capítulo, (finalmente) vamos escrever funções produtivas. O primeiro exemplo é a função área, que retorna a área de um círculo dado um raio:

function área(raio)
    a = π * raio^2
    return a
end

Já vimos o comando return anteriormente, mas em uma função produtiva o comando return inclui uma expressão. Este comando significa: "Retorne imediatamente a partir desta função e leve a expressão seguinte como um valor de retorno". Como essa expressão pode ser arbitrariamente complicada, então poderíamos ter escrito uma função mais sucinta:

function área(raio)
    π * raio^2
end

O valor retornado por uma função é o valor do último comando executado, que, por padrão, é a última expressão no corpo da definição da função.

Além disso, variáveis temporárias como a e as instruções explícitas return podem contribuir para a depuração.

Às vezes, é prático ter diversos comandos return, uma em cada ramo de uma condicional:

function valorAbsoluto(x)
    if x < 0
        return -x
    else
        return x
    end
end

E já que estas instruções de retorno estão em alternativas exclusivas, somente uma é executada.

Assim que um comando return é executado, a função termina sem executar qualquer comando posterior. O código que aparece após um comando return, ou qualquer outro lugar que o fluxo de execução jamais possa alcançar, é chamado de código morto.

Em uma função produtiva, recomendamos garantir que todos os caminhos possíveis através do programa chegue em um comando de retorno. Por exemplo:

function valorAbsoluto(x)
    if x < 0
        return -x
    end
    if x > 0
        return x
    end
end

Essa função está incorreta porque se x for 0, nenhuma das condições é verdadeira, e a função termina sem chegar a um comando return. Quando o fluxo de execução chega ao final de uma função, o valor de retorno é nothing, que não é o valor absoluto de 0.

julia> show(valorAbsoluto(0))
nothing
Dica

Julia tem uma função interna chamada abs que calcula valores absolutos.

Exercício 6-1

Escreva uma função comparar que recebe dois valores, x e y, e retorna 1 se x > y, 0 se x == y e -1 se x < y.

Desenvolvimento Incremental

À medida que se escreve funções maiores, talvez aconteça de você passar mais tempo depurando.

Ao lidar com programas cada vez mais complexos, tente um processo chamado desenvolvimento incremental, que adiciona e testa apenas uma pequena quantidade de código por vez, evitando assim longas sessões de depuração.

Para exemplificar esse processo, suponha que você queira determinar a distância entre dois pontos, dada pelas coordenadas \(\left(x_1, y_1\right)\) e \(\left(x_2, y_2\right)\). Pelo teorema de Pitágoras, a distância é:

\[\begin{equation} {d = \sqrt{(x_2 - x_1)^2 + (y_2 - y_1)^2}} \end{equation}\]

O primeiro passo é avaliar como deve ser uma função de distância em Julia. Em outras palavras, quais são as entradas (os parâmetros) e qual é a saída (o valor de retorno)?

Neste exemplo, as entradas são dois pontos, que pode ser representado por quatro números. Já o valor de retorno é a distância representada por um valor de ponto flutuante.

Com isso, pode-se esboçar a função:

function distância(x₁, y₁, x₂, y₂)
    0.0
end

Obviamente que essa versão não determina as distâncias pois ela sempre retorna zero. Mas é sintaticamente correta, e roda, o que significa que você pode testá-la antes de complicá-la. Os números dos subscritos estão disponíveis na codificação de caracteres Unicode (\_1 TAB, \_2 TAB, etc.).

Para testar a nova função, chame-a com argumentos exemplificados a seguir:

distância(1, 2, 4, 6)

Escolhemos estes valores para que a distância horizontal seja 3 e a vertical seja 4; logo, o resultado é 5 porque é a hipotenusa de um triângulo retângulo 3-4-5. Quando testar uma função, o aconselhável é já saber a resposta correta.

Nesse ponto, como já confirmamos que a função está sintaticamente correta, então podemos começar a adicionar código ao corpo. Um passo subsequente razoável é encontrar as diferenças \(x_2 - x_1\) e \(y_2 - y_1\). A próxima versão da função armazena esses valores em variáveis temporárias que são mostradas com a macro @show.

function distância(x₁, y₁, x₂, y₂)
    dx = x₂ - x₁
    dy = y₂ - y₁
    @show dx dy
    0.0
end

Se a função estiver funcionando, ela deve exibir dx = 3 e dy = 4. Nesse caso, sabemos que a função está obtendo os argumentos certos e executando os primeiros cálculos corretamente. Caso contrário, há apenas poucas linhas para analisar.

Em seguida, somamos os quadrados de dx e de dy:

function distância(x₁, y₁, x₂, y₂)
    dx = x₂ - x₁
    dy = y₂ - y₁
    d² = dx^2 + dy^2
    @show d²
    0.0
end

Você executaria o programa mais uma vez nesse estágio e verificaria a saída (que deveria ser 25). Números sobrescritos também estão disponíveis (\^2 TAB). Por fim, usa-se sqrt para calcular e retornar o resultado final:

function distância(x₁, y₁, x₂, y₂)
    dx = x₂ - x₁
    dy = y₂ - y₁
    d² = dx^2 + dy^2
    sqrt(d²)
end

Se a função rodar corretamente, pronto. Caso contrário, convém mostrar o valor de sqrt(d²) antes do comando return.

A versão final da função não exibe nada quando é executada, retornando apenas um valor. As instruções de impressão que escrevemos são úteis para a depuração, mas depois que a função estiver funcionando, devemos removê-las. Um código como esse é chamado andaime porque é útil para criar o programa, embora não faça parte do produto final.

Ao iniciar, você deve adicionar apenas uma ou duas linhas de código por vez. À medida que você adquire mais experiência, pode se escrever e depurar pedaços maiores. De qualquer forma, o desenvolvimento incremental pode economizar muito tempo de depuração.

Os principais aspectos do processo são:

  1. Comece com um programa funcional e faça pequenas alterações incrementais. A qualquer momento, se houver um erro, você deverá ter uma boa ideia de onde ele está.

  2. Use variáveis para armazenar valores intermediários de modo que você possa visualizá-los e conferi-los.

  3. Uma vez que o programa esteja funcionando, você pode querer retirar algumas das instruções andaimes ou consolidar múltiplos comandos em expressões compostas, desde que não dificulte a leitura do programa.

Exercício 6-2

Use o desenvolvimento incremental para escrever uma função chamada hipotenusa que retorna o comprimento da hipotenusa de um triângulo retângulo, a partir dos comprimentos dos outros dois catetos como argumentos. Registre cada estágio do processo de desenvolvimento à medida que avança.

Composição

Como já esperado, você pode chamar uma função de dentro da outra. Para exemplificar isso, escreveremos uma função que calcula a área do círculo a partir de dois pontos, o centro do círculo e um ponto no perímetro.

Suponha que o ponto central é indicado pelas variáveis xc e yc, e o ponto de perímetro é indicado por xp e yp. O primeiro passo é encontrar o raio do círculo, dado pela distância entre estes dois pontos. Note que acabamos de escrever a função distância:

raio = distância(xc, yc, xp, yp)

O próximo passo é calcular a área de um círculo a partir desse raio, e por isso também escrevemos essa função:

resultado = área(raio)

Encapsulando esses passos em uma função, temos:

function área_círculo(xc, yc, xp, yp)
    raio = distância(xc, yc, xp, yp)
    resultado = área(raio)
    return resultado
end

As variáveis temporárias raio e resultado são úteis para o desenvolvimento e a depuração, mas depois que o programa estiver funcionando, podemos torná-lo mais conciso fazendo:

function área_círculo(xc, yc, xp, yp)
    área(distância(xc, yc, xp, yp))
end

Funções Booleanas

As funções podem retornar valores booleanos, o que muitas vezes é conveniente para ocultar testes complicados dentro de funções. Por exemplo:

function é_divisível(x, y)
    if x % y == 0
        return true
    else
        return false
    end
end

Frequentemente se atribui nomes de funções booleanas que soam como perguntas de sim/não; neste caso, é_divisível retorna true ou false para saber se x é divisível por y.

Eis um exemplo:

julia> é_divisível(6, 4)
false
julia> é_divisível(6, 3)
true

O resultado do operador == é um valor booleano, logo podemos escrever a função de forma mais sucinta por meio de um comando direto:

function é_divisível(x, y)
    x % y == 0
end

Funções booleanas são constantemente utilizadas em estruturas condicionais:

if é_divisível(x, y)
    println("x é divisível por y")
end

Talvez seja tentador escrever algo como:

if é_divisível(x, y) == true
    println("x é divisível por y")
end

No entanto, a comparação adicional com true é desnecessária.

Exercício 6-3

Escreva uma função está_entre(x, y, z) que retorna true se x ≤ y ≤ z ou false caso contrário.

Mais Recursividade

Mostramos apenas uma pequena fração de Julia, mas você pode estar interessado em saber que essa fração é uma linguagem de programação completa, significando que qualquer coisa que possa ser calculada pode ser expressa nessa linguagem. Qualquer programa já escrito pode ser reescrito usando apenas os recursos da linguagem que você aprendeu até o momento (na verdade, você precisaria de alguns comandos para controlar dispositivos como mouse, discos, etc., mas isso é tudo).

Essa afirmação é um exercício não trivial provado pela primeira vez por Alan Turing, um dos primeiros cientistas da computação (alguns argumentariam que ele era matemático, mas muitos dos primeiros cientistas da computação começaram como matemáticos). Por isso, esta prova é conhecida como a Tese de Turing. Para uma discussão mais completa (e precisa) da Tese de Turing, recomendo o livro Introdução à teoria da computação de Michael Sipser.

Para ter uma noção do que você pode fazer com as ferramentas que sabe até agora, avaliaremos algumas funções matemáticas definidas recursivamente. Uma definição recursiva é semelhante a uma definição circular, no sentido de que a definição contém uma chamada de si própria. Uma definição totalmente circular não é muito vantajosa:

vorpal

Um adjetivo usado para descrever algo que é vorpal.

Ver essa definição no dicionário pode ser irritante. Por outro lado, se consultar a definição da função fatorial, denotada com o símbolo \(!\), poderá obter algo assim:

\[\begin{equation} {n! = \begin{cases} 1& \textrm{se}\ n = 0 \\ n (n-1)!& \textrm{se}\ n > 0 \end{cases}} \end{equation}\]

Essa definição diz que o fatorial de 0 é 1, e o fatorial de qualquer outro valor \(n\) é \(n\) multiplicado pelo fatorial de \(n-1\).

Então \(3!\) é 3 vezes \(2!\), que é 2 vezes \(1!\), que é 1 vezes \(0!\). Colocando tudo junto, \(3!\) é igual a 3 vezes 2 vezes 1 vezes 1, que dá 6.

Se puder escrever uma definição recursiva de algo, pode-se escrever um programa em Julia para testá-la. A primeira etapa é decidir quais devem ser os parâmetros. E nesse caso, é evidente que o fatorial recebe um número inteiro:

function fatorial(n) end

Se o argumento for 0, basta retornar 1:

function fatorial(n)
    if n == 0
        return 1
    end
end

Caso contrário, e esta é a parte interessante, temos que fazer uma chamada recursiva para encontrar o fatorial de n-1 para depois multiplicá-lo por n:

function fatorial(n)
    if n == 0
        return 1
    else
        recursão = fatorial(n-1)
        resultado = n * recursão
        return resultado
    end
end

O fluxo de execução deste programa é similar ao fluxo de contagem regressiva em Recursão. Chamando fatorial do valor 3:

Como 3 não é 0, seguimos para o segundo ramo e calculamos o fatorial de n-1 …​

 Como 2 não é 0, seguimos para o segundo ramo e calculamos o fatorial de n-1 …​

  Como 1 não é 0, seguimos para o segundo ramo e calculamos o fatorial de n-1 …​

   Como 0 é igual a 0, seguimos para o primeiro ramo e temos o resultado 1 sem efetuar
    mais chamadas recursivas.

  O valor de retorno (= 1) é multiplicado por n (que é 1), e o resultado é devolvido.

 O valor de retorno (= 1), é multiplicado por n (que é 2), e o resultado é devolvido.

O valor de retorno (= 2) é multiplicado por n (que é 3), e o resultado (= 6), torna-se o valor de retorno da chamada da função que iniciou todo esse processo.

fig61
Figura 9. Diagrama de Pilha

Diagrama de Pilha mostra como fica o diagrama de pilha para esta sequência de chamadas de função.

Os valores de retorno são exibidos quando devolvidos de volta para cima da pilha. Em cada quadro, o valor de retorno é o valor de resultado, dado pelo produto de n com recursão.

No último quadro, as variáveis locais recursão e resultado não existem porque o ramo que as cria não é executado.

Dica

Em Julia, a função factorial calcula o fatorial de um número inteiro.

Salto de Fé

Ler programas seguindo o fluxo de execução pode se tornar rapidamente exaustivo. Uma alternativa que eu chamo de "salto de fé" faz a leitura conforme o fluxo de execução e quando se chega a uma chamada de função, assume-se que a função funciona corretamente e devolve o resultado correto.

Na verdade, você já está praticando este salto de fé no uso de funções embutidas. Quando você chama cos ou exp, você não investiga os corpos dessas funções. Você apenas assume que funcionam já que as pessoas que escreveram as funções embutidas eram bons programadores.

A mesma prática ocorre quando você chama uma de suas próprias funções. Por exemplo, em Funções Booleanas, escrevemos a função é_divisível que determina se um número é divisível por outro. Depois de nos convencermos de que essa função está correta ao examinar seu código e testar, podemos usá-la sem olhar para o corpo novamente.

O mesmo se aplica aos programas recursivos. Ao chegar na chamada recursiva, em vez de acompanhar o fluxo de execução, deve-se assumir que a chamada recursiva funciona (retorna o resultado correto) e depois se perguntar: “Supondo que possa encontrar o fatorial do \(n-1\), posso calcular o fatorial do \(n\)?” Sim, multiplicando por \(n\).

É claro que é um pouco estranho assumir que a função funciona corretamente quando ainda não se terminou de escrevê-la, mas é por isso que se chama salto de fé!

Mais Um Exemplo

Após fatorial, o exemplo mais familiar de uma função matemática definida recursivamente é a sequência de Fibonacci, cuja definição é (consulte https://pt.wikipedia.org/wiki/Sequência_de_Fibonacci):

\[\begin{equation} {fib(n) = \begin{cases} 0& \textrm{se}\ n = 0 \\ 1& \textrm{se}\ n = 1 \\ fib(n-1) + fib(n-2)& \textrm{se}\ n > 1 \end{cases}} \end{equation}\]

Traduzindo para Julia, tem-se:

function fib(n)
    if n == 0
        return 0
    elseif n == 1
        return 1
    else
        return fib(n-1) + fib(n-2)
    end
end

Se você tentar acompanhar o fluxo de execução aqui, mesmo para valores razoavelmente pequenos de n, sua cabeça vai enlouquecer. No entanto, de acordo com o salto de fé, se presumir que as duas chamadas recursivas funcionam sem erros, fica nítido que o resultado certo é obtido a partir da soma delas.

Verificação de Tipos

O que ocorre se chamarmos fatorial e atribuirmos 1.5 como argumento?

julia> fatorial(1.5)
ERROR: StackOverflowError:
Stacktrace:
 [1] fatorial(::Float64) at ./REPL[3]:2

Parece uma recursão infinita. Como pode ser? A função tem um caso base—quando n == 0. Mas se n não for um número inteiro, podemos perder o caso base e ficar recursivo para sempre.

Na primeira chamada recursiva, o valor de n é 0.5. No próximo, é -0.5. A partir daí, vai diminuindo e ficando cada vez mais negativo, mas nunca será 0.

Temos duas escolhas. Podemos tentar generalizar a função fatorial para trabalhar com números de ponto flutuante, ou podemos fazer fatorial verificar o tipo de argumento. Na primeira opção, tem-se a função gama que está um pouco além do escopo deste livro. Logo, vamos adotar a segunda opção.

Podemos usar o operador embutido isa para verificar o tipo do argumento. Ainda falando no assunto, também podemos certificar que o argumento seja positivo:

function fatorial(n)
    if !(n isa Int64)
        error("Fatorial é definido somente para números inteiros.")
    elseif n < 0
        error("Fatorial não é definido para números inteiros negativos.")
    elseif n == 0
        return 1
    else
        return n * fatorial(n-1)
    end
end

Enquanto o primeiro caso-base aborda os não-inteiros; o segundo aborda os inteiros negativos. Para esses dois casos, o programa exibe uma mensagem de erro e devolve nothing para indicar que algo deu errado:

julia> fatorial("fred")
ERROR: Fatorial é definido somente para números inteiros.
julia> fatorial(-2)
ERROR: Fatorial não é definido para números inteiros negativos.

Se passarmos pelas duas verificações, concluímos que n é positivo ou zero, logo, conseguimos provar que a recursão termina.

Esse programa demonstra um padrão às vezes de guardião. Os dois primeiros condicionais atuam como guardiões, protegendo o código de valores que podem causar um erro. Além disso, os guardiões tornam possível provar a execução sem erro do código.

Em Capturando Exceções, veremos uma alternativa mais flexível para mostrar uma mensagem de erro: levantando uma exceção.

Depuração

Dividir um programa grande em funções menores cria pontos de verificação naturais para a depuração. Caso uma função não esteja funcionando, há três possibilidades para analisar:

  • Há algo errado com os argumentos que a função está recebendo; ou seja, uma precondição não foi satisfeita.

  • Há algo errado com a função; isto é, uma pós-condição não foi satisfeita.

  • Há algo errado com o valor de retorno ou com a maneira como ele está sendo utilizado.

Para descartar a primeira possibilidade de erro, você pode imprimir no início da função os valores dos parâmetros (e possivelmente seus tipos). Ou pode escrever um código que verifique claramente as precondições.

Se os parâmetros parecerem bons, imprima o valor de retorno adicionando um comando de impressão antes de cada comando de retorno. Se possível, verifique o resultado à mão. Considere chamar a função com valores que facilitem a conferência do resultado (como em Desenvolvimento Incremental).

Caso a função pareça estar funcionando, observe a chamada de função para garantir que o valor de retorno esteja sendo usado corretamente (ou se está mesmo sendo usado!).

Adicionar comandos de impressão no início e no final de uma função pode facilitar o acompanhamento do fluxo de execução. Por exemplo, aqui está uma versão de fatorial com comandos print:

function fatorial(n)
    espaço = " " ^ (4 * n)
    println(espaço, "fatorial ", n)
    if n == 0
        println(espaço, "retornando 1")
        return 1
    else
        recursão = fatorial(n-1)
        resultado = n * recursão
        println(espaço, "retornando ", resultado)
        return resultado
    end
end

espaço é uma string de espaços que atua na indentação da saída:

julia> fatorial(4)
                fatorial 4
            fatorial 3
        fatorial 2
    fatorial 1
fatorial 0
retornando 1
    retornando 1
        retornando 2
            retornando 6
                retornando 24
24

Caso o fluxo de execução não esteja claro, esse tipo de saída de impressões pode ser útil. Leva algum tempo para usar andaimes eficientemente, mas um pouco de andaime pode economizar muita depuração.

Glossário

variável temporária

Uma variável que armazena um valor intermediário em um cálculo difícil.

código morto

O pedaço de um programa que nunca será executado, geralmente porque aparece após um comando de retorno.

desenvolvimento incremental

Um plano de desenvolvimento de programa que tem o objetivo de evitar a depuração, adicionando e testando apenas uma pequena quantidade de código de cada vez.

andaime

O código usado no decorrer do desenvolvimento do programa, porém não faz parte da versão final.

guardião

Um padrão de programação que usa a estrutura condicional para conferir e tratar de circunstâncias que possam levar a erros.

Exercícios

Exercício 6-4

Desenhe o diagrama de pilha correspondente ao seguinte programa. O que o programa imprime?

function b(z)
    produto = a(z, z)
    println(z, " ", produto)
    produto
end

function a(x, y)
    x = x + 1
    x * y
end

function c(x, y, z)
    total = x + y + z
    quadrado = b(total)^2
    quadrado
end

x = 1
y = x + 1
println(c(x, y+3, x+y))
Exercício 6-5

Veja a função de Ackermann, \(A(m, n)\), definida como:

\[\begin{equation} {A(m, n) = \begin{cases} n+1& \textrm{se}\ m = 0 \\ A(m-1, 1)& \textrm{se}\ m > 0\ \textrm{e}\ n = 0 \\ A(m-1, A(m, n-1))& \textrm{se}\ m > 0\ \textrm{e}\ n > 0. \end{cases}} \end{equation}\]

Consulte https://pt.wikipedia.org/wiki/Fun%C3%A7%C3%A3o_de_Ackermann. Escreva uma função chamada ack que calcula a função de Ackermann. Use sua função para avaliar ack(3, 4), que é 125. O que ocorre quando aumentam os valores de m e n?

Exercício 6-6

Palíndromo é uma palavra que se soletra igualmente nos dois sentidos, como "arara" e "reviver". Definindo recursivamente, uma palavra é um palíndromo se a primeira e a última letras forem as mesmas e se o meio também for um palíndromo.

As funções seguintes recebem uma string como argumento e retornam respectivamente a primeira, a última letra e as letras do meio:

function primeira(palavra)
    primeira = firstindex(palavra)
    palavra[primeira]
end

function última(palavra)
    última = lastindex(palavra)
    palavra[última]
end

function meio(palavra)
    primeira = firstindex(palavra)
    última = lastindex(palavra)
    palavra[nextind(palavra, primeira) : prevind(palavra, última)]
end

Veremos como eles funcionam no Strings.

  1. Teste estas funções. O que acontece se você chamar meio para uma string de duas letras? E de uma letra? E no caso da string vazia, que é escrita "" e não tem nenhuma letra?

  2. Escreva uma função chamada é_palíndromo que recebe um argumento string e retorna true se for um palíndromo e false caso contrário. Lembre-se de que você pode usar a função interna length para verificar o comprimento de uma string.

Exercício 6-7

Um número, \(a\), é dito uma potência de \(b\) se for divisível por \(b\) e \(\frac{a}{b}\) for potência de \(b\). Escreva uma função chamada é_potência que dados os parâmetros a e b devolve true se a for uma potência de b.

Dica

Você terá que considerar o caso base.

Exercício 6-8

O máximo divisor comum (MDC) de \(a\) e \(b\) é o maior número que divide os dois sem sobrar resto.

Uma maneira de encontrar o MDC de dois números é baseada na observação de que se \(r\) é o resto da divisão de \(a\) por \(b\), então mcd(a, b) = mcd(b, r). Para o caso base, considere que mdc(a, 0) = a.

Escreva a função mdc que recebe os parâmetros a e b e retorna o máximo divisor comum.

Crédito: Este exercício é baseado em um exemplo do livro Structure and Interpretation of Computer Programs de Abelson e Sussman.

7. Iteração

Esse capítulo é sobre iteração, que é a habilidade de executar um bloco de argumentos repetidamente. Vimos um tipo de iteração usando recursão em Recursão. Vimos também outro tipo, usando um laço for em Repetição Simples. Neste capítulo, veremos um outro tipo, que usa uma declaração while (enquanto em Português). Mas antes temos que falar um pouco mais sobre atribuição de variável.

Reatribuição

Como você deve ter descoberto, é permitido fazer mais de uma atribuição para as mesmas variáveis. Uma nova atribuição faz com que uma variável existente se refira a um novo valor (e pare de se referir ao valor antigo).

julia> x = 5
5
julia> x = 7
7

A primeira vez que exibimos x, o seu valor é 5; na segunda vez, o seu valor é 7.

fig71
Figura 10. Diagrama de estado

Diagrama de estado mostra como uma reatribuição funciona por meio de um diagrama de estado.

Neste ponto, queremos abordar um assunto comum de confusão. Como o Julia usa o sinal de igual (=) para a atribuição, é tentador interpretar uma afirmação como a = b como uma proposição matemática de igualdade; isto é, a afirmação de que a e b são iguais. Mas essa interpretação está errada.

Primeiro que a igualdade é uma relação simétrica e a atribuição não é. Por exemplo, em matemática, se \(a=7\) então \(7=a\). Mas em Julia, a atribuição a=7 é permitida e 7=a não.

Também na matemática, uma proposição de igualdade ou é verdadeira ou é falsa para sempre. Se \(a=b\) agora, então \(a\) será sempre igual a \(b\). Em Julia, uma atribuição pode fazer duas variáveis iguais, mas elas não precisam ficar assim:

julia> a = 5
5
julia> b = a    # a e b são iguais
5
julia> a = 3    # a e b não são mais iguais
3
julia> b
5

A terceira linha altera o valor de a mas não altera o valor de b, portanto eles não são mais iguais.

Atenção

A reatribuição de variáveis geralmente é útil, mas você deve usá-la com cuidado. Se os valores das variáveis mudarem com frequência, isso pode dificultar a leitura e a depuração do código.

Não é permitido definir uma função que tenha o mesmo nome de uma variável definida anteriormente.

Atualizando Variáveis

Um tipo comum de reatribuição é uma atualização, onde o novo valor da variável depende do antigo.

julia> x = x + 1
8

Isso significa “pegue o valor atual de x, adicione um, e então atualize x ao novo valor.”

Se você tenta atualizar uma variável que não existe, uma mensagem de erro aparecerá, porque o Julia avalia o lado direito antes de atribuir um valor a x:

julia> y = y + 1
ERROR: UndefVarError: y not defined

Antes que você possa atualizar uma variável, deve-se inicializá-la geralmente com uma atribuição simples:

julia> y = 0
0
julia> y = y + 1
1

Atualizar uma variável adicionando 1 é chamado de incremento; e subtraindo 1 é chamado de decremento.

A Declaração while

Os computadores são frequentemente usados para automatizar tarefas repetitivas. Repetir tarefas idênticas ou semelhantes sem cometer erros é algo que os computadores fazem bem, ao contrário das pessoas. Em um programa de computador, a repetição também é chamada de iteração.

Já vimos duas funções, contador_regressivo e imprima_n, que iteram usando recursão. Como a iteração é muito comum, Julia dispõe de recursos para simplificá-la. Um deles é a declaração for que vimos em Repetição Simples. Voltaremos a este assunto mais tarde.

A outra é a declaração while. Aqui está a versão de contagem_regressiva que utiliza a declaração while:

function contagem_regressiva(n)
    while n > 0
        print(n, " ")
        n = n - 1
    end
    println("Decolar!")
end

Você pode ler a declaração while quase como se estivesse em Português, traduzindo while por enquanto. Ela significa “Enquanto n for maior que 0, exiba o valor de n e depois diminua n. Quando chegar a 0, imprima a palavra Decolar!”

Formalmente, segue abaixo o fluxo de execução de uma declaração while:

  1. Determinar se a condição é verdadeira ou falsa.

  2. Se for falsa, saia da declaração while e continue a execução para o próximo comando.

  3. Se a condição for verdadeira, execute os comandos do corpo e então volte para o passo 1.

Esse tipo de fluxo é chamado de laço porque o terceiro passo retorna ao topo.

O corpo do laço deve mudar o valor de uma ou mais variáveis até que eventualmente a condição se torna falsa e o laço é finalizado. Caso contrário, o laço irá se repetir para sempre, ou seja, um laço infinito. Uma fonte inesgotável de diversão para os cientistas da computação são as instruções nos frascos de shampoo, “ensaboe, enxague, repita”, pois é um laço infinito.

No caso da função contagem_regressiva, podemos provar que o laço é finalizado: se n é zero ou negativo, o laço nunca termina. Caso contrário, n vai diminuindo durante o laço e então eventualmente chegará a 0.

Para alguns laços, não é tão fácil de provar. Por exemplo:

function seq(n)
    while n != 1
        println(n)
        if n % 2 == 0        # n é par
            n = n / 2
        else                 # n é ímpar
            n = n*3 + 1
        end
    end
end

A condição para esse laço é n != 1, então esse laço irá continuar até que n seja 1, o que faz a condição ser falsa.

A cada passada do laço, o programa tem como saída o valor n que é verificado se é par ou ímpar. Se é par, n é dividido por 2. Se é ímpar, o valor de n é substituído por n*3 + 1. Por exemplo, se o argumento da sequência é 3, os valores de n recebidos são 3, 10, 5, 16, 8, 4, 2, 1.

Já que n às vezes cresce e às vezes decresce, não existe uma demonstração óbvia de que n resultará em 1 ou que o programa termine. Para alguns valores particulares de n, podemos demonstrar que termina. Por exemplo, se o valor inicial é uma potência de dois, n será sempre par durante o laço até que chega em 1. O exemplo anterior finaliza essa sequência, a partir de 16.

A parte difícil é provar que esse programa finaliza para todos os valores positivos de n. Até agora ninguém foi capaz de provar ou desprovar isso! (Consulte https://pt.wikipedia.org/wiki/Conjectura_de_Collatz.)

Exercício 7-1

Reescreva a função imprima_n de Recursão usando iteração ao invés de recursão.

break

Às vezes, você não sabe que é hora de terminar um laço até chegar na metade do corpo. Neste caso você pode utilizar a declaração break para sair do laço.

Por exemplo, suponha que você queira receber entradas do usuário até que ele digite concluído. Poderia-se escrever:

while true
    print("> ")
    linha = readline()
    if linha == "concluído"
        break
    end
    println(linha)
end
println("Concluído!")

A condição deste laço é true, que é sempre verdade, então o laço será executado até chegar na declaração break.

A cada iteração, a solicitação ao usuário ocorre por meio de um sinal de maior (">"). Se o usuário digitar concluído, então a declaração break finaliza o laço. Caso contrário, o programa mostrará o que o usuário digitar e voltará ao topo do laço. Aqui está um exemplo de execução:

> não está concluído
não está concluído
> concluído
Concluído!

Essa maneira de escrever laços é comum porque você pode verificar a condição em qualquer lugar do laço (não apenas no topo) e você pode expressar a condição de parada afirmativamente ("pare quando isso acontecer") ao invés de negativamente ("continue enquanto isso acontece").

continue

A declaração break sai do laço. Quando uma declaração continue é encontrado dentro de um laço, salta-se para o início do laço da próxima iteração, pulando a execução de comandos dentro do corpo do laço da iteração atual. Por exemplo:

for i in 1:10
    if i % 3 == 0
        continue
    end
    print(i, " ")
end

Output:

1 2 4 5 7 8 10

Se i é divisível por 3, a declaração continue para na iteração atual e a próxima iteração é iniciada. Apenas os números no intervalo entre 1 a 10 não divisíveis por 3 são exibidos.

Raízes Quadradas

Laços são frequentemente usados em programas que calculam resultados numéricos começando com um valor aproximado e aprimorando-o iterativamente.

Por exemplo, uma maneira de calcular raízes quadradas é através do método de Newton. Suponha que você queira saber a raiz quadrada de \(a\). Se você começar com uma estimativa qualquer, \(x\), pode-se calcular uma estimativa melhor com a seguinte fórmula:

\[\begin{equation} {y = \frac{1}{2}\left(x + \frac{a}{x}\right)} \end{equation}\]

Por exemplo, se \(a\) é 4 e \(x\) é 3:

julia> a = 4
4
julia> x = 3
3
julia> y = (x + a/x) / 2
2.1666666666666665

O resultado está mais próximo da resposta correta (\(\sqrt 4 = 2\)). Se repetirmos o processo com a nova estimativa, ficará mais próximo ainda:

julia> x = y
2.1666666666666665
julia> y = (x + a/x) / 2
2.0064102564102564

Depois de mais algumas atualizações, a estimativa é quase exata:

julia> x = y
2.0064102564102564
julia> y = (x + a/x) / 2
2.0000102400262145
julia> x = y
2.0000102400262145
julia> y = (x + a/x) / 2
2.0000000000262146

Em geral, não sabemos antecipadamente quantos passos são necessários para obter a resposta certa, mas sabemos quando chegamos lá porque a estimativa para de mudar:

julia> x = y
2.0000000000262146
julia> y = (x + a/x) / 2
2.0
julia> x = y
2.0
julia> y = (x + a/x) / 2
2.0

Quando y == x, podemos parar. Aqui está um laço que começa com uma estimativa inicial x, e melhora até parar de mudar:

while true
    println(x)
    y = (x + a/x) / 2
    if y == x
        break
    end
    x = y
end

Essa função funciona bem para a maior parte dos valores de a, mas em geral é perigoso testar igualdade com pontos flutuantes. Pontos flutuantes não são totalmente exatos: a maioria dos números racionais, como \(\frac{1}{3}\), e números irracionais, como \(\sqrt 2\), não podem ser representados exatamente com um tipo Float64.

Em vez de verificar se x e y são exatamente iguais, é mais seguro usar a função interna abs para calcular o valor absoluto, ou magnitude, da diferença entre eles:

if abs(y-x) < ε
    break
end

Onde ε (\varepsilon TAB) possui um valor como 0.0000001 que determina o quão suficientemente próximo está.

Algoritmos

O método de Newton é um exemplo de um algoritmo: um processo mecânico para resolver uma categoria de problemas (nesse caso, o cálculo de raízes quadradas).

Para entender o que é um algoritmo, talvez seja interessante começar com algo que não é um algoritmo. Quando você aprendeu a multiplicar unidades, você provavelmente memorizou a tabuada. De fato, você memorizou 100 soluções específicas. Esse tipo de conhecimento não é um algoritmo.

Mas se você fosse “preguiçoso”, talvez tivesse aprendido alguns truques. Por exemplo, para encontrar o produto de \(n\) e 9, você pode escrever \(n-1\) no primeiro dígito e \(10-n\) no segundo dígito. Esse truque é uma solução geral para multiplicar qualquer unidade por 9. Isso é um algoritmo!

Similarmente, as técnicas que você aprendeu para a adição com transporte de unidades, a subtração com empréstimos e a divisão longa são todos algoritmos. Uma das características dos algoritmos é que eles não exigem nenhuma inteligência para serem executados. São processos mecânicos em que cada passo segue a partir do último, de acordo com um conjunto simples de regras.

Apesar da execução de algoritmos ser chata, a construção é interessante, intelectualmente desafiadora e uma parte central da ciência da computação.

Algumas das coisas que as pessoas fazem naturalmente, sem dificuldade ou conscientemente pensado, são as mais difíceis de expressar por algoritmos. Compreender a linguagem natural é um bom exemplo. Todos nós fazemos isso, mas até agora ninguém foi capaz de explicar como fazemos, pelo menos não na forma de um algoritmo.

Depuração

Ao começar a escrever programas maiores, você pode passar mais tempo com a depuração. Mais código significa mais chances de cometer um erro e mais lugares para os erros se esconderem.

Uma maneira de reduzir o tempo da depuração é a “depuração por bissecção”. Por exemplo, se houver 100 linhas no seu programa e você verificá-las uma de cada vez, serão necessárias 100 etapas.

Em vez disso, tente quebrar o problema ao meio. Olhe no meio do programa, ou por perto, para um valor intermediário que você pode verificar. Adicione uma declaração print (ou qualquer outra coisa que tenha um propósito de verificação) e execute o programa.

Se a verificação da região do meio estiver incorreta, deve haver um problema na primeira metade do programa. Se estiver correta, o problema está na segunda metade.

Toda vez que você executa uma verificação como essa, reduz-se pela metade o número de linhas que se precisa averiguar. Após seis etapas (que é menor que 100), você reduziria para uma ou duas linhas de código, pelo menos em teoria.

Na prática, nem sempre é claro onde é o "meio do programa" e nem sempre é possível verificá-lo. Não faz sentido contar linhas e encontrar o ponto médio exato. Em vez disso, pense nos locais do programa em que pode haver erros e nos locais onde é fácil fazer uma verificação. Em seguida, escolha um local onde você acha que as chances são as mesmas de que o erro seja antes ou depois da verificação.

Glossário

reatribuição

Atribuindo um novo valor a uma variável que já existe.

atualização

Uma atribuição em que o novo valor da variável depende do antigo.

inicialização

Uma atribuição que fornece um valor inicial a uma variável que será atualizada.

incremento

Uma atualização que aumenta o valor de uma variável (frequentemente em um).

decremento

Uma atualização que diminui o valor de uma variável.

iteração

Execução repetida de um conjunto de comandos usando uma chamada de função recursiva ou um laço.

declaração while

Comando que permite iterações controladas por uma condição.

declaração break

Comando que permite saltar fora de um laço.

declaração continue

Comando dentro de um laço que salta para o início do laço da próxima iteração.

laço infinito

Um laço no qual a sua condição de parada nunca é satisfeita.

algoritmo

Um processo geral para resolver uma categoria de problemas.

Exercícios

Exercício 7-2

Copie o laço de Raízes Quadradas e encapsule-o em uma função chamada minha_raiz que usa a variável a como um parâmetro e escolha um valor razoável de x que retorne uma estimativa da raiz quadrada de a.

Para testá-la, escreva uma função chamada avalia_raiz_quadrada que imprime uma tabela como esta:

a   mysqrt             sqrt               diff
-   ------             ----               ----
1.0 1.0                1.0                0.0
2.0 1.414213562373095  1.4142135623730951 2.220446049250313e-16
3.0 1.7320508075688772 1.7320508075688772 0.0
4.0 2.0                2.0                0.0
5.0 2.23606797749979   2.23606797749979   0.0
6.0 2.449489742783178  2.449489742783178  0.0
7.0 2.6457513110645907 2.6457513110645907 0.0
8.0 2.82842712474619   2.8284271247461903 4.440892098500626e-16
9.0 3.0                3.0                0.0

A primeira coluna é um número, a; a segunda coluna é a raiz quadrada de a calculada com minha_raiz; a terceira coluna é a raiz quadrada calculada por sqrt; a quarta coluna é o valor absoluto da diferença entre as duas estimativas.

Exercício 7-3

A função interna Meta.parse recebe uma string e transforma-a em uma expressão. Essa expressão pode ser avaliada em Julia com a função Core.eval. Por exemplo:

julia> expr = Meta.parse("1+2*3")
:(1 + 2 * 3)
julia> eval(expr)
7
julia> expr = Meta.parse("sqrt(π)")
:(sqrt(π))
julia> eval(expr)
1.7724538509055159

Escreva uma função chamada avalie_laço que solicite iterativamente ao usuário, pegue a entrada recebida e avalie-a usando eval e depois imprime o resultado. A função deve continuar até o usuário digitar concluído e depois retornar o valor da última expressão avaliada.

Exercício 7-4

O matemático Srinivasa Ramanujan encontrou uma série infinita que pode ser usada para gerar uma aproximação numérica de \(\frac{1}{\pi}\):

\[\begin{equation} {\frac{1}{\pi}=\frac{2\sqrt2}{9801}\sum_{k=0}^\infty\frac{(4k)!(1103+26390k)}{(k!)^4 396^{4k}}} \end{equation}\]

Escreva uma função chamada estima_pi que use essa fórmula para calcular e retornar uma estimativa de π. Ela deve usar um laço while para calcular os termos da soma até que o último termo seja menor que 1e-15 (que é a notação do Julia para \(10^{-15}\)). Você pode verificar o resultado comparando-o com π.

8. Strings

Strings não são como inteiros, pontos flutuante e tipos booleanos. Uma string é uma sequência, o que significa que ela está em uma coleção ordenada de outros valores. Neste capítulo você verá como acessar os caracteres que compõem uma string e irá aprender sobre algumas funções auxiliares de strings fornecidas pelo Julia.

Caracteres

Falantes da língua portuguesa estão familiarizados com caracteres como as letras do alfabeto (A, B, C, …​), numerais e a pontuação comum. Esses caracteres são padronizados e mapeados para valores inteiros entre 0 e 127 pelo padrão ASCII (American Standard Code for Information ou "Código Padrão Americano de Intercâmbio de Informações")

Existem, é claro, muitos outros caracteres usados em línguas diferentes do Português, incluindo variantes dos caracteres ASCII com acentos e outras modificações, scripts relacionados como o Cirílico e o Grego e scripts completamente não relacionados ao ASCII e ao Português, incluindo Árabe, Chinês, Hebreu, Hindi, Japonês e Koreano.

O padrão Unicode lida com as complexidades do que exatamente é um caractere e é geralmente aceitado como o padrão definitivo que resolve esse problema. Ele fornece um número único para todo caractere em uma escala mundial.

Um valor Char representa um único caractere e está cercado por aspas simples:

julia> 'x'
'x': ASCII/Unicode U+0078 (category Ll: Letter, lowercase)
julia> '🍌'
'🍌': Unicode U+1F34C (category So: Symbol, other)
julia> typeof('x')
Char

Até mesmo emojis fazem parte do padrão Unicode. (\:banana: TAB)

Uma String é uma Sequência

Uma string é uma sequência de caracteres. Você pode acessar os caracteres um de cada vez com o operador colchetes:

julia> fruta = "banana"
"banana"
julia> letra = fruta[1]
'b': ASCII/Unicode U+0062 (category Ll: Letter, lowercase)

O segundo comando seleciona o primeiro caractere de fruta e o atribui para letra.

A expressão em colchetes é chamada de índice. O índice indica qual caractere da sequência você deseja (por isso o nome).

Toda indexação no Julia começa em 1, o primeiro elemento de qualquer objeto inteiramente indexado é encontrado no índice 1 e o último no índice end:

julia> fruta[end]
'a': ASCII/Unicode U+0061 (category Ll: Letter, lowercase)

Você pode usar como índice uma expressão que contém variáveis e operadores:

julia> i = 1
1
julia> fruta[i+1]
'a': ASCII/Unicode U+0061 (category Ll: Letter, lowercase)
julia> fruta[end-1]
'n': ASCII/Unicode U+006E (category Ll: Letter, lowercase)

Mas o valor do índice precisa ser um inteiro. Caso contrário você recebe:

julia> letra = fruta[1.5]
ERROR: MethodError: no method matching getindex(::String, ::Float64)

length

length é uma função interna que retorna o número de caracteres em uma string:

julia> frutas = "🍌 🍎 🍐"
"🍌 🍎 🍐"
julia> tamanho = length(frutas)
5

Para obter a última letra da string, você pode ficar tentado a fazer algo como:

julia> last = frutas[tamanho]
' ': ASCII/Unicode U+0020 (category Zs: Separator, space)

Mas você pode não conseguir o que espera.

Strings são codificadas usando a codificação UTF-8. UTF-8 é uma codificação de largura variável, o que significa que nem todos os caracteres estão codificados com o mesmo número de bytes.

A função sizeof retorna o número de bytes em uma string:

julia> sizeof("🍌")
4

Como um emoji é codificado em 4 bytes e a indexação de strings é baseado em bytes, o quinto elemento de frutas é um ESPAÇO.

Isso significa também que nem todo índice de bytes em uma string UTF-8 é necessariamente um índice válido para um caractere. Se você indexar em uma string com um índice de bytes inválido, será gerado um erro:

julia> frutas[2]
ERROR: StringIndexError("🍌 🍎 🍐", 2)

No caso de frutas, o caractere 🍌 é um caractere de quatro bytes, então os índices 2, 3 e 4 são inválidos e o índice do próximo caractere é 5; esse próximo índice válido pode ser calculado por nextind(frutas, 1) e o índice depois dele por nextind(frutas, 5) e assim por diante.

Travessia

Muitos problemas computacionais envolvem o processamento de uma string, um caractere de cada vez. Geralmente eles começam no início, selecionando cada caractere por vez, fazendo algo com ele, e continuando até o final. Esse padrão de processamento é denominado de travessia. Um jeito de escrever uma travessia é com um laço while:

índice = firstindex(frutas)
while índice <= sizeof(frutas)
    letra = frutas[índice]
    println(letra)
    global índice = nextind(frutas, índice)
end

Esse laço faz a travessia da string e exibe cada letra em uma linha por si só. A condição do laço é index <= sizeof(fruta), então quando o índice é maior que o número de bytes em uma string, a condição é false, e o corpo do laço não executa.

A função firstindex retorna o primeiro índice de bytes válido. A palavra-chave global antes de índice indica que nós queremos reatribuir a variável índice definida em Main (ver Variáveis Globais).

Exercício 8-1

Escreva uma função que recebe uma string como argumento e exibe as letras ao contrário, uma por linha.

Outra maneira de escrever uma travessia é com o laço for:

for letra in frutas
    println(letra)
end

Cada vez que o laço é percorrido, o próximo caractere na string é atribuido para a variável letra. O laço continua até que não haja mais caracteres sobrando.

O próximo exemplo mostra como usar concatenação (multiplicação de strings) e um laço for para gerar séries abecedárias (isto é, em ordem alfabética). No livro de Robert McCloskey, Make way for Ducklings (Abram caminho para os Patinhos), os nomes dos patinhos são Jack, Kack, Lack, Mack, Nack, Ouack, Pack, e Quack. Esse laço gera os nomes em ordem:

prefixos = "JKLMNOPQ"
sufixo = "ack"

for letra in prefixos
    println(letra * sufixo)
end

Output:

Jack
Kack
Lack
Mack
Nack
Oack
Pack
Qack

É claro, isso não está completamente correto pois “Ouack” e “Quack” estão incorretos.

Exercício 8-2

Modifique o programa para consertar este erro.

Fatias de Strings

Um segmento de uma string é chamado de fatia. Selecionar uma fatia é similar a selecionar um caractere:

julia> str = "Chapolin Colorado";

julia> str[1:8]
"Chapolin"

O operador [n:m] retorna a parte da string do n-ésimo byte até o m-ésimo byte. Então o mesmo cuidado é necessário como para a indexação simples.

A palavra-chave end pode ser usada para indicar o último byte da string:

julia> str[10:end]
"Colorado"

Se o primeiro índice é maior que o segundo, o resultado é uma string vazia, representada por aspas duplas:

julia> str[8:7]
""

Uma string vazia não contém nenhum caractere e possui tamanho 0, mas fora isso, é igual a qualquer outra string.

Exercício 8-3

Continuando este exemplo, o que você acha que str[:] significa? Experiemente e veja.

String são Imutáveis

É tentador usar o operador [] no lado esquerdo da atribuição, com a intenção de mudar um caractere de uma string. Por exemplo:

julia> cumprimento = "Olá, Mundo!"
"Olá, Mundo!"
julia> cumprimento[1] = 'E'
ERROR: MethodError: no method matching setindex!(::String, ::Char, ::Int64)

O motivo deste erro é de que strings são imutáveis, o que significa que você não pode mudar uma string existente. O melhor que você pode fazer é criar uma nova string que é uma variação da original:

julia> cumprimento = "E" * cumprimento[2:end]
"Elá, Mundo!"

Esse exemplo concatena uma nova primeira letra em uma fatia de cumprimento. Ele não tem efeito algum na string original.

Interpolação de Strings

Construir strings usando concatenação pode vir a ser um incômodo. Para reduzir a necessidade dessas chamadas verbosas para string ou multiplicações repetidas, o Julia permite interpolação de strings usando $:

julia> cumprimento = "Olá"
"Olá"
julia> quem = "Mundo"
"Mundo"
julia> "$cumprimento, $(quem)!"
"Olá, Mundo!"

Isso é mais legível e conveniente do que concatenação de strings: cumprimento * ", " * quem * "!"

O valor da menor expressão inteira após o $ é tomado como o valor que deve ser interpolado na sequência. Assim, você pode interpolar qualquer expressão em uma string usando parênteses:

julia> "1 + 2 = $(1 + 2)"
"1 + 2 = 3"

Buscando

O que a função a seguir faz?

function buscar(palavra, letra)
    índice = firstindex(palavra)
    while índice <= sizeof(palavra)
        if palavra[índice] == letra
            return índice
        end
        índice = nextind(palavra, índice)
    end
    -1
end

De certo modo, buscar é o inverso do operador []. Ao invés de pegar um índice e extrair o caractere correspondente, ela recebe o caractere e busca o índice aonde este caractere aparece. Se o caractere não é encontrado, a função retorna -1.

Esse é o primeiro exemplo que nós vimos de uma declaração return dentro de um laço. Se palavra[índice] == letra, a função sai do laço e retorna imediatamente.

Se o caractere não aparece na string, o programa sai do laço normalmente e retorna -1.

Percorrer uma sequência e retornar o objeto que estamos procurando quando achamos-o é um padrão de computação chamado de busca.

Exercício 8-4

Modifique busca para que ela tenha um terceiro parâmetro, o índice em palavra aonde ela deve começar a procurar.

Realizando Laços e Contando

O seguinte programa conta o número de vezes que a letra a aparece em uma string:

palavra = "banana"
contador = 0
for letra in palavra
    if letra == 'a'
        global contador = contador + 1
    end
end
println(contador)

Esse programa demonstra outro padrão de programação chamado contador. A variável contador é inicializada com 0 e incrementada toda vez que um a é encontrado. Quando a função sai do laço, contador contém o resultado-o número total de a’s.

Exercício 8-5

Encapsule esse código em uma função chamada conte e a generealize para que ela aceite a string e a letra como argumentos.

Depois reescreva a função de modo que ao invés de percorrer a string, ela usa a versão com três parâmetros de busca da seção anterior.

A Biblioteca de Strings

O Julia fornece funções que executam uma variedade de operações utéis com strings. Por exemplo, a função uppercase recebe uma string e retorna uma nova string com todas suas letras maiúsculas.

julia> uppercase("Olá, Mundo!")
"OLÁ, MUNDO!"

Acontece que, existe uma função chamada findfirst que é bastante similar a função busca que nós escrevemos:

julia> findfirst("a", "banana")
2:2

Na verdade, a função findfirst é mais geral que a nossa função; ela pode achar substrings, não apenas caracteres:

julia> findfirst("na", "banana")
3:4

Por padrão, findfirst começa no início da string, mas a função findnext recebe um terceiro argumento, o índice onde ela deve começar:

julia> findnext("na", "banana", 4)
5:6

O Operador

O operador (\in TAB) é um operador booleano que recebe um caractere e uma string e retorna true se o caractere aparece na string:

julia> 'a' ∈ "banana"    # 'a' em "banana"
true

Por exemplo, a seguinte função imprime todas as letras da palavra1 que também aparecem na palavra2:

function em_ambos(palavra1, palavra2)
    for letra in palavra1
        if letra ∈ palavra2
            print(letra, " ")
        end
    end
end

Com variáveis de nomes bem escolhidos, o Julia às vezes lê como Inglês. Você poderia ler este laço da seguinte forma: “para (cada) letra na (primeira) palavra, se (a) letra é um elemento da (segunda) palavra, imprima (a) letra”

Isso é o que você recebe se você compara "maçãs" e "laranjas":

julia> em_ambos("maçãs", "laranjas")
a s

Comparação de Strings

O operador relacional funciona em strings. Para ver se duas strings são iguais:

palavra = "Abacaxi"
if palavra == "banana"
    println("Tudo certo, bananas.")
end

Outras operações relacionais são utéis para colocar palavras em ordem alfabética:

if palavra < "banana"
    println("Sua palavra, $palavra, vem antes de banana.")
elseif palavra > "banana"
    println("Sua palavra, $palavra, vem depois de banana.")
else
    println("Tudo certo, bananas.")
end

O Julia não trata letras maiúsculas e minúsculas do mesmo jeito que as pessoas lidam. Todas as letras maiúsculas vem antes de todas as letras minúsculas, então:

Sua palavra, Abacaxi, vem antes de banana.
Dica

Uma maneira comum de resolver este problema é convertendo strings para um formato padrão, como todas minúsculas, antes de efetuar a comparação.

Depuração

Quando você usa índices para percorrer os valores em uma sequência, é difícil de obter o começo e o fim da travessia direito. Aqui está uma função que deveria comparar duas palavras e retornar true se uma das palavras é o inverso da outra, mas ela contém dois erros:

function é_inversa(palavra1, palavra2)
    if length(palavra1) != length(palavra2)
        return false
    end
    i = firstindex(palavra1)
    j = lastindex(palavra2)
    while j >= 0
        j = prevind(palavra2, j)
        if palavra1[i] != palavra2[j]
            return false
        end
        i = nextind(palavra1, i)
    end
    true
end

A primeira declaração if verifica se as palavras são do mesmo tamanho. Se não, nós podemos retornar false imediatamente. Caso contrário, para o resto da função, nós podemos assumir que as palavras são do mesmo tamanho. Isso é um exemplo do padrão guardião.

i e j são índices: i percorre a palavra1 de frente para trás, enquanto j percorre a palavra2 de trás para frente. Se nós acharmos duas letras que não são iguais, nós podemos retornar false imediatamente. Se nós passarmos pelo laço inteiro e todas as letras forem iguais, nós retornamos true.

A função lastindex retorna o último índice de bytes válido de uma string e prevind o último índice válido de um caractere.

Se nós testarmos essa função com as palavras "pare" e "erap", nós esperamos que o valor de retorno seja true, mas nós obtemos false:

julia> é_inversa("pare", "erap")
false

Para depurar esse tipo de erro, o primeiro passo é imprimir os valores dos índices:

    while j >= 0
        j = prevind(palavra2, j)
        @show i j
        if palavra1[i] != palavra2[j]

Agora quando executamos novamente o programa, obtemos mais informações:

julia> é_inversa("pare", "erap")
i = 1
j = 3
false

Na primeira iteração do laço, o valor de j é 3, que tem que ser 4. Isso pode ser consertado movendo j = prevind(palavra2, j) para o final do laço while.

Se consertamos este erro e executarmos novamente o programa, obtemos:

julia> é_inversa("pare", "erap")
i = 1
j = 4
i = 2
j = 3
i = 3
j = 2
i = 4
j = 1
i = 5
j = 0
ERROR: BoundsError: attempt to access String
  at index [5]

Desta vez um BoundsError foi gerado. O valor de i é 5, que está fora do alcance para a string "erap".

Exercício 8-6

Execute o programa em papel, mudando os valores de i e j durante cada iteração. Encontre e conserte o segundo erro nesta função.

Glossário

sequência

Uma coleção ordenada de valores no qual cada valor é identificado por um índice inteiro.

padrão ASCII

Um padrão de codificação de caracteres para comunicação eletrônica que especifica 128 caracteres.

padrão Unicode

Um padrão da indústria da computação para a codificação consistente, representação, e tratamento de texto expressado na maioria dos sistemas de escrita do mundo.

índice

Um valor inteiro usado para selecionar um item em uma sequência, como um caractere em uma string. Em Julia índices começam em 1.

codificação UTF-8

Uma codificação de comprimento variável de caractere capaz de codificar todas as 1112064 pontos de código usando um a quatro bytes de 8-bit.

travessia

Iterar sobre os items de uma sequência, realizando operações similares em cada um deles.

fatia

Uma parte de uma string especificado por um alcance de índices.

string vazia

Uma string sem caracteres e comprimento 0, representada por aspas duplas.

imutável

A propriedade de uma sequência no qual seus items não podem ser mudados.

interpolação de strings

O processo de avaliar uma string que contém um ou mais espaços reservados, produzindo um resultado no qual os espaços reservados são substituidos por seus valores correspondentes.

busca

Um padrão de travessia que para quando acha o que está procurando.

contador

Uma variável usada para contar algo, geralmente inicializada para zero e em seguida incrementada.

Exercícios

Exercício 8-7

Leia a documentação das funções de string em https://docs.julialang.org/en/v1/manual/strings/. Você talvez queira experimentar algumas delas para garantir que você entende como elas funcionam. strip e replace são particulamente utéis.

A documentação usa uma sintaxe que pode ser confusa. Por exemplo, em search(string::AbstractString, chars::Chars, [start::Integer]), os colchetes indicam argumentos opcionais. Então string e chars são obrigatórios, mas start é opcional.

Exercício 8-8

Existe uma função embutida chamada count que é similar à função em Realizando Laços e Contando. Leia a documentação desta função e a use para contar o número de a’s em "banana".

Exercício 8-9

Uma fatia de string pode receber um terceiro índice. O primeiro especifica o começo, o terceiro o fim e o segundo o “tamanho do passo”; isto é, o número de espaços entre caracteres sucessivos. Um tamanho de passo de 2 significa que andamos de dois em dois; 3 significa de três em três e etc.

julia> fruta = "banana"
"banana"
julia> fruta[1:2:6]
"bnn"

Um tamanho de passo -1 percorre a palavra ao contrário, então a fatia [end:-1:1] gera uma string reversa.

Use esse idioma para escrever uma versão de uma linha de é_palíndromo do Exercício 6-6.

Exercício 8-10

As seguintes função são todas planejadas para checar se a string contém alguma letra minúscula, mas pelo menos algumas delas estão erradas. Para cada função, descreva o que a função faz de fato (assumindo que o parâmetro é uma string).

function qualquer_minúscula1(s)
    for c in s
        if islowercase(c)
            return true
        else
            return false
        end
    end
end

function qualquer_minúscula2(s)
    for c in s
        if islowercase('c')
            return "true"
        else
            return "false"
        end
    end
end

function qualquer_minúscula3(s)
    for c in s
        flag = islowercase(c)
    end
    flag
end

function qualquer_minúscula4(s)
    flag = false
    for c in s
        flag = flag || islowercase(c)
    end
    flag
end

function qualquer_minúscula5(s)
    for c in s
        if !islowercase(c)
            return false
        end
    end
    true
end
Exercício 8-11

Uma cifra de César é uma forma fraca de criptografia que envolve “rotacionar” cada letra por um número fixo de lugares. Rotacionar uma letra significa deslocar ela através do alfabeto, retornando ao começo se necessário, então ’A’ rotacionada por 3 é ’D’ e ’Z’ rotacionada por 1 é ’A’.

Para rotacionar uma palavra, rotacione cada letra pelo mesmo valor. Por exemplo "ovo" rotacionado por 16 é "ele" e "teve" rotacionado por 22 é "para". No filme 2001: Odisseia no espaço, o computador de bordo é chamado de HAL, que é IBM rotacionado por -1.

Escreva uma função chamada rotacionapalavra que recebe uma string e um inteiro como parâmetros, e retorna uma nova string que contém as letras da string original rotacionada pela inteiro fornecido.

Dica

Você pode querer usar as funções embutidas Int, que converte um caractere para um código numérico, e Char, que converte códigos numéricos para caracteres. Letras do alfabeto são codificadas em ordem alfabética, então por exemplo:

julia> Int('c') - Int('a')
2

Por que c é a terceira letra do alfabeto. Mas tome cuidado: os códigos numéricos para letras maiúsculas são diferentes.

julia> Char(Int('A') + 32)
'a': ASCII/Unicode U+0061 (category Ll: Letter, lowercase)

Piadas potencialmente ofensivas na internet são algumas vezes codificadas em ROT13, que é uma cifra de César com rotação 13. Se você não é facilmente ofendido, encontre e codifique algumas delas.

9. Estudo de Caso: Jogo de Palavras

Este capítulo apresenta o segundo estudo de caso, que envolve solucionar um quebra-cabeças onde devemos encontrar palavras com certas propriedades. Por exemplo, encontraremos os palíndromos mais longos em Inglês e as palavras cujas letras aparecem em ordem alfabética. Além disso, apresentaremos outro plano de desenvolvimento de programa: redução até um problema previamente resolvido.

Lendo Listas de Palavras

Para os exercícios deste capítulo, será necessária uma lista de palavras em inglês. Existem muitas listas de palavras disponíveis na internet, e a mais adequada para o nosso propósito é uma das listas de palavras coletadas e disponibilizadas em domínio público por Grady Ward como parte do projeto léxico da Moby (consulte https://wikipedia.org/wiki/Moby_Project). É uma lista com 113.809 palavras cruzadas oficiais; isto é, termos considerados válidos em palavras cruzadas e outros jogos com palavras. Na coleção de Moby, o nome do arquivo é 113809of.fic e você pode fazer o download de uma cópia, com o nome mais simples palavras.txt, em https://github.com/JuliaIntro/JuliaIntroBR.jl/blob/master/data/palavras.txt.

Este arquivo está em texto não formatado e portanto, pode ser aberto com um editor de texto, inclusive também pode ser lido em Julia. A função interna open precisa receber o nome do arquivo como parâmetro e retorna um fluxo (stream) de arquivo usado para a leitura do arquivo.

julia> arquivo_entrada = open("palavras.txt")
IOStream(<file palavras.txt>)

arquivo_entrada é um fluxo de arquivo usado para a entrada de dados e quando não for mais necessário, deve ser fechado com close(arquivo_entrada).

O Julia fornece várias funções para leitura, como a readline, que lê os caracteres do arquivo até chegar a um comando de nova linha e retorna o resultado como uma string:

julia> readline(arquivo_entrada)
"aa"

A primeira palavra em Inglês nesta lista especial é "aa", que é uma espécie de lava.

O fluxo de arquivo monitora sua localização no arquivo; portanto, se chamarmos novamente o comando readline, recebemos a próxima palavra:

julia> readline(arquivo_entrada)
"aah"

O termo subsequente em inglês é "aah", que é uma palavra perfeitamente legítima, então pare de me olhar desse jeito.

Você também pode usar um objeto de arquivo em um laço for. Este programa lê palavras.txt e imprime cada palavra, uma por linha:

for linha in eachline("palavras.txt")
    println(linha)
end

Exercícios

Exercício 9-1

Escreva um programa que leia palavras.txt e imprima somente as palavras com mais de 20 caracteres (sem contar os espaços em branco).

Exercício 9-2

Em 1939, Ernest Vincent Wright publicou um romance de 50.000 palavras chamado Gadsby que não contém a letra e. Como e é a letra mais usada em inglês, escrever um romance assim não é fácil.

De fato, é difícil elaborar um único pensamento sem conter essa letra mais usada. É lento no início, mas com cautela e horas de treinamento, você pode gradualmente aprimorar essa habilidade até ficar mais fácil. Em Inglês, este parágrafo não contém a letra e.

Tudo bem, vou parar agora.

Escreva uma função chamada sem_e que informa true se a palavra especificada não contém a letra e.

Altere o programa que você acabou de escrever para que ele imprima apenas as palavras que não possuem e e informe a porcentagem de palavras na lista que não possuem e.

Exercício 9-3

Escreva uma função chamada evita que recebe uma palavra e uma string de letras proibidas e devolve true se a palavra não contém nenhuma das letras proibidas.

Modifique o seu programa para receber uma string de letras proibidas digitada pelo usuário e imprimir o número de palavras que não contêm nenhuma dessas letras. Você consegue encontrar uma combinação de 5 letras proibidas que exclua a menor quantidade de palavras?

Exercício 9-4

Escreva uma função denominada usa_somente que recebe uma palavra e uma string de letras e devolve true se a palavra contém apenas as letras da string. Você pode fazer uma frase usando apenas as letras acefhlo? Uma diferente de "Hoe alfafa?"

Exercício 9-5

Escreva uma função chamada usa_todas que recebe uma palavra e uma string de letras obrigatórias, e devolve true se a palavra usar todas as letras obrigatórias ao menos uma vez. Quantas palavras existem que usam todas as vogais aeiou? E que tal aeiouy?

Exercício 9-6

Escreva uma função chamada é_abecedário que retorna true se as letras em uma palavra aparecem em ordem alfabética (letras repetidas não são um problema). Quantas palavras em ordem alfabética existem?

Todos os exercícios da seção anterior têm algo em comum; eles podem ser resolvidos por meio de um padrão de busca. O exemplo mais simples é:

function sem_e(palavra)
    for letra in palavra
        if letra == 'e'
            return false
        end
    end
    true
end

O laço for percorre os caracteres das palavras. Se encontrarmos a letra e, podemos retornar imediatamente false; caso contrário, temos que avançar para a próxima letra. Se sairmos do laço do modo convencional, isso significa que não encontramos um e, por isso retornamos true.

Você poderia escrever esta função de forma mais sucinta usando o operador (\notin TAB), mas comecei com essa versão porque ela mostra a lógica do padrão de busca.

evita é uma versão mais geral do sem_e, apesar de ter a mesma estrutura:

function evita(palavra, proibido)
    for letra in palavra
        if letra ∈ proibido
            return false
        end
    end
    true
end

Devolvemos false assim que encontrarmos uma letra proibida e se chegarmos ao final do laço, retornamos true.

usa_somente é parecido, exceto que o sentido da condição se inverte:

function usa_somente(palavra, válido)
    for letra in palavra
        if letra ∉ válido
            return false
        end
    end
    true
end

Ao invés de uma lista de letras proibidas, temos uma série de letras válidas. Se encontrarmos uma letra em palavra que não seja válida, então podemos retornar false.

usa_todas é similar, exceto que invertemos a posição da palavra e a sequência de letras:

function usa_todas(palavra, obrigatória)
    for letra in obrigatória
        if letra ∉ palavra
            return false
        end
    end
    true
end

Em vez de percorrer as letras nas palavras, o laço percorre as letras obrigatórias. Se alguma das letras obrigatórias não aparecer na palavra, então retornamos false.

Se você estivesse realmente pensando como um cientista da computação, você teria identificado que usa_todas era um caso de um problema previamente solucionado e teria escrito:

function usa_todas(palavra, obrigatórias)
    usa_somente(obrigatória, palavra)
end

Este é um exemplo de um plano de desenvolvimento para um programa chamado redução para um problema previamente resolvido, no qual você reconhece o problema em que está trabalhando como uma instância de um problema resolvido e aplica uma solução existente.

Laço com Índices

Escrevi as funções da seção anterior com laços for porque só precisava dos caracteres nas strings, sem precisar operar com os índices.

Em é_abecedário, temos que comparar letras adjacentes, o que é um pouco trabalhoso com um laço for:

function é_abecedário(palavra)
    i = firstindex(palavra)
    anterior = palavra[i]
    j = nextind(palavra, i)
    for c in palavra[j:end]
        if c < anterior
            return false
        end
        anterior = c
    end
    true
end

Uma alternativa é usar a recursão:

function é_abecedário(palavra)
    if length(palavra) <= 1
        return true
    end
    i = firstindex(palavra)
    j = nextind(palavra, i)
    if palavra[i] > palavra[j]
        return false
    end
    é_abecedário(palavra[j:end])
end

Uma outra opção é usar um laço while:

function é_abecedário(palavra)
    i = firstindex(palavra)
    j = nextind(palavra, 1)
    while j <= sizeof(palavra)
        if palavra[j] < palavra[i]
            return false
        end
        i = j
        j = nextind(palavra, i)
    end
    true
end

O laço começa em i=1 e j=nextind(palavra, 1) e termina quando j>sizeof(palavra). A cada iteração no laço, ele compara o i-ésimo caractere (que você pode pensar como sendo o caractere atual) com o j-ésimo caractere (que você pode pensar como o próximo).

Se a posição do próximo caractere é alfabeticamente antecedente à posição do caractere atual, descobrimos uma quebra na tendência alfabética e retornamos false.

Ao chegarmos ao final do laço sem uma falha, então a palavra passa no teste. Para se convencer de que o laço termina corretamente, considere a palavra "acenos" como um exemplo.

Aqui está uma versão de é_palíndromo que usa dois índices; um está no início e sobe, e o outro está no final e desce.

function é_palíndromo(palavra)
    i = firstindex(palavra)
    j = lastindex(palavra)
    while i<j
        if palavra[i] != palavra[j]
            return false
        end
        i = nextind(palavra, i)
        j = prevind(palavra, j)
    end
    true
end

Ou podemos fazer a redução para um problema resolvido anteriormente e escrever:

function é_palíndromo(palavra)
    é_inversa(palavra, palavra)
end

usando é_inversa de Depuração.

Depuração

Testar programas é difícil. As funções neste capítulo são relativamente fáceis de testar já que você pode verificar os resultados manualmente. Mesmo assim, é difícil ou impossível escolher um conjunto de palavras para testar todos os erros possíveis.

Selecionando sem_e como exemplo, há dois casos óbvios a serem avaliados: palavras com e devem retornar false e palavras sem e devem retornar true. Não vai ser difícil encontrar um exemplo de cada.

Dentro de cada caso, existem subcasos menos óbvios. Entre as palavras que possuem um "e", você deve testar as palavras com "e" no início, no final e em algum lugar no meio. Devem-se testar palavras longas, curtas e muito curtas, como a string vazia. A string vazia é um exemplo de um caso especial, que é um dos casos não óbvios onde geralmente os erros se ocultam.

Além dos casos de teste gerados, você também pode testar seu programa com uma lista de palavras como palavras.txt. Ao avaliar a saída, podem-se detectar erros, mas tenha cuidado: você pode encontrar um tipo de erro (palavras que não devem ser incluídas, mas são) e não o outro tipo (palavras que devem ser incluídas, mas não são).

Em geral, o teste pode te ajudar a encontrar bugs, embora não seja fácil gerar um bom conjunto de casos de teste e, mesmo que você consiga, não é possível ter certeza de que seu programa está correto. De acordo com um lendário cientista da computação:

Testar programas pode ser usado para mostrar a presença de bugs, mas nunca para mostrar a ausência deles!

— Edsger W. Dijkstra

Glossário

fluxo de arquivo

Um valor que representa um arquivo aberto.

redução a um problema previamente resolvido

Uma maneira de resolver um problema, tratando-o como um caso de um problema resolvido anteriormente.

caso especial

Um caso de teste que é atípico ou que não é óbvio (e com menor chance de ser abordado corretamente).

Exercícios

Exercício 9-7

Esta pergunta é baseada em um quebra-cabeças que foi transmitido no programa de rádio chamado Car Talk (https://www.cartalk.com/puzzler/browse):

Diga-me uma palavra com três letras duplas consecutivas. Darei a você algumas palavras que quase se qualificam, mas não são. Por exemplo, a palavra committee (comitê em inglês), c-o-m-m-i-t-t-e-e. Seria ótimo, exceto pelo i que está infiltrado na palavra. Ou Mississippi: M-i-s-s-i-s-s-i-p-p-i. Se você pudesse tirar aqueles i’s, funcionaria. Mas há uma palavra que possui três pares consecutivos de letras e, pelo que sei, essa pode ser a única palavra. Claro que há provavelmente mais 500, mas só consigo pensar em uma. Qual é a palavra?

Escreva um programa para encontrar essa palavra em Inglês.

Exercício 9-8

Aqui está outro desafio de Car Talk (https://www.cartalk.com/puzzler/browse):

Eu estava dirigindo na estrada outro dia e notei meu odômetro. Conforme a maioria dos odômetros, seis dígitos são mostrados, em milhas inteiras. Por exemplo, se meu carro tivesse percorrido 300.000 milhas, então eu veria 3-0-0-0-0-0.

Agora, o que vi naquele dia foi muito interessante. Percebi que os últimos quatro dígitos eram palíndromos; ou seja, eles são lidos tanto para a frente como para trás. Por exemplo, 5-4-4-5 é um palíndromo, então meu odômetro poderia ter lido 3-1-5-4-4-5.

Uma milha depois, os últimos 5 números se tornaram um palíndromo. Por exemplo, poderia ter lido 3-6-5-4-5-6. Uma milha depois disso, os 4 dos 6 números do meio formaram um palíndromo. E você está pronto para isso? Uma milha depois, todos os 6 se tornaram um palíndromo!

A pergunta é, qual o número que estava no odômetro quando olhei pela primeira vez?

Escreva um programa em Julia que teste todos os números de seis dígitos e mostre qualquer número que atenda esses requisitos.

Exercício 9-9

Eis um outro desafio Car Talk que você pode resolver com uma busca (https://www.cartalk.com/puzzler/browse):

Recentemente, visitei minha mãe e percebemos que os dois dígitos que compõem minha idade quando trocados resultavam em sua idade. Por exemplo, se ela tem 73 anos, tenho 37 anos. Imaginávamos com que frequência isso acontecia ao longo dos anos, mas acabamos mudamos de assunto e nunca chegamos a uma resposta.

Quando cheguei em casa, descobri que os dígitos de nossas idades foram trocados seis vezes até agora. Também descobri que, se tivermos sorte, isso acontecerá novamente em alguns anos, e se tivermos muita sorte, isso acontecerá mais uma vez depois disso. Ou seja, isso teria acontecido oito vezes. Então a pergunta é: qual a minha idade agora?

Escreva um programa em Julia que procure as soluções deste desafio.

Dica

A função lpad pode ser útil para você.

10. Listas

Este capítulo apresenta um dos tipos internos mais úteis do Julia, as listas. Você também aprenderá sobre objetos e o que pode acontecer quando se tem mais de um nome para o mesmo objeto.

Uma Lista é uma Sequência

Assim como uma string, uma lista é uma sequência de valores. Em uma string, os valores são caracteres; em uma lista, eles podem ser de qualquer tipo. Os valores de uma lista são chamados de elementos ou por vezes itens.

Existem diversas maneiras de criar uma nova lista; a mais simples é inserir os elementos entre colchetes ([ ]), da seguinte forma:

[10, 20, 30, 40]
["sapo", "coelho", "aranha"]

O primeiro exemplo é uma lista de quatro inteiros. O segundo é uma lista de três strings. Os elementos de uma lista não precisam ser do mesmo tipo. A seguinte lista contém uma string, um float, um inteiro e uma outra lista:

["bruxa", 2.0, 5, [10, 20]]

Uma lista dentro de outra lista é dita aninhada.

Uma lista que não contém elementos é chamada de lista vazia; você pode criar uma lista vazia com colchetes vazios, [].

Como esperado, podemos atribuir valores de listas a variáveis:

julia> vegetais = ["Cenoura", "Brócolis", "Alface"];

julia> números = [42, 123];

julia> vazia = [];

julia> print(vegetais, " ", números, " ", vazia)
["Cenoura", "Brócolis", "Alface"] [42, 123] Any[]

A função typeof pode ser usada para encontrar o tipo de lista:

julia> typeof(vegetais)
Array{String,1}
julia> typeof(números)
Array{Int64,1}
julia> typeof(vazia)
Array{Any,1}

O tipo de lista é especificado entre as chaves e é composto por um tipo e um número. O número indica as dimensões. A lista vazia contém valores do tipo Any., isto é, ela pode conter valores de todos os tipos.

Listas são Mutáveis

A sintaxe para acessar elementos de uma lista é a mesma para acessar caracteres de uma string com o operador colchetes. A expressão dentro dos colchetes especifica o índice. Lembre-se que o índice começa em 1:

julia> vegetais[1]
"Cenoura"

Ao contrário de strings, listas são mutáveis. Quando os colchetes aparecem no lado esquerdo de uma atribuição, eles identificam o elemento da lista que será atribuido:

julia> números[2] = 5
5
julia> print(números)
[42, 5]

O segundo elemento de números, que costumava ser 123, agora é 5.

Diagrama de estado mostra o diagrama de estado para vegetais, números e vazia.

fig101
Figura 11. Diagrama de estado

Uma lista é representada por uma caixa e pelos elementos da lista dentro dela. vegetais refere-se a uma lista com três elementos indexados 1, 2 e 3. números contém dois elementos; o diagrama mostra que o valor do segundo elemento foi reatribuído de 123 para 7. vazia refere-se a uma lista sem elementos.

Os índices das listas funcionam do mesmo jeito que os índices de strings (mas sem as ressalvas do UTF-8):

  • Qualquer expressão inteira pode ser utilizada como um índice.

  • Se você tentar ler ou escrever um elemento que não existe, um BoundsError (erro de limites) será gerado.

  • A palavra-chave end aponta para o último índice da lista.

O operador também funciona em listas:

julia> "Cenoura" ∈ vegetais
true
julia> "Beterraba" in vegetais
false

Percorrendo uma Lista

A maneira mais comum de percorrer os elementos de uma lista é através de um laço for. A sintaxe é a mesma para strings:

for vegetal in vegetais
    println(vegetal)
end

Isso funciona bem se você precisa apenas ler os elementos de uma lista. Mas se você deseja escrever ou atualizar os elementos, você precisa dos índices. Um jeito comum de fazer isto é utilizando a função interna eachindex:

for i in eachindex(números)
    números[i] = números[i] * 2
end

Esse laço percorre a lista e atualiza cada elemento. length retorna o número de elementos de uma lista. Toda vez que o laço é percorrido, i obtém o índice do próximo elemento. O comando de atribuição no corpo usa i para ler o valor antigo do elemento e atribuir ao novo valor:

Utilizar o laço for sobre uma lista vazia nunca executa o corpo:

for x in []
    println("Isso nunca acontecerá.")
end

Embora uma lista possa conter outra lista, a lista aninhada ainda conta como um único elemento. O comprimento dessa lista é quatro:

["spam", 1, ["João", "Pedro", "Gabriel"], [1, 2, 3]]

Operador de Fatias

O operador de fatias também funciona para listas.

julia> t = ['a', 'b', 'c', 'd', 'e', 'f'];

julia> print(t[1:3])
['a', 'b', 'c']
julia> print(t[3:end])
['c', 'd', 'e', 'f']

O operador de fatia [:], faz uma cópia de toda a lista:

julia> print(t[:])
['a', 'b', 'c', 'd', 'e', 'f']

Como as listas são mutáveis, geralmente é útil fazer uma cópia antes de executar operações que modificam listas.

O operador de fatias no lado esquerdo de uma atribuição pode atualizar múltiplos elementos:

julia> t[2:3] = ['x', 'y'];

julia> print(t)
['a', 'x', 'y', 'd', 'e', 'f']

Biblioteca de Listas

O Julia fornece funções que operam com listas. Por exemplo, push! adiciona um novo elemento ao final de uma lista:

julia> t = ['a', 'b', 'c'];

julia> push!(t, 'd');

julia> print(t)
['a', 'b', 'c', 'd']

append! adiciona os elementos da segunda lista ao final da primeira:

julia> t1 = ['a', 'b', 'c'];

julia> t2 = ['d', 'e'];

julia> append!(t1, t2);

julia> print(t1)
['a', 'b', 'c', 'd', 'e']

Esse exemplo deixa t2 sem modificações.

sort! organiza os elementos da lista do menor para o maior:

julia> t = ['d', 'c', 'e', 'b', 'a'];

julia> sort!(t);

julia> print(t)
['a', 'b', 'c', 'd', 'e']

sort retorna uma cópia dos elementos da lista em ordem:

julia> t1 = ['d', 'c', 'e', 'b', 'a'];

julia> t2 = sort(t1);

julia> print(t1)
['d', 'c', 'e', 'b', 'a']
julia> print(t2)
['a', 'b', 'c', 'd', 'e']
Nota

Por convenção, no Julia ! é anexado a nomes de funções que modificam seus argumentos.

Mapeamento, Filtro e Redução

Para somar todos os números de uma lista, podemos utilizar um laço da seguinte forma:

function soma_todos(t)
    total = 0
    for x in t
        total += x
    end
    total
end

total é iniciado em 0. Toda vez que o laço é percorrido, += captura um elemento da lista. O operador += fornece um jeito fácil de atualizar uma variável. Esse comando de atribuição aumentada,

total += x

é equivalente a

total = total + x

Quando o laço é iniciado, total acumula a soma dos elementos; uma variável usada dessa maneira é chamada de acumuladora.

Adicionar elementos de uma lista é uma operação tão comum que o Julia fornece uma função interna, sum:

julia> t = [1, 2, 3, 4];

julia> sum(t)
10

Uma operação como essa que combina uma sequência de elementos a um único valor por vezes é chamada de operação de redução.

Muitas vezes, você deseja percorrer uma lista enquanto cria outra. Por exemplo, a função à seguir recebe uma lista de strings e retorna uma nova lista que contém as strings com todos os seus caracteres maiúsculos:

function todas_maiúsculas(t)
    res = []
    for s in t
        push!(res, uppercase(s))
    end
    res
end

res é inicializada com uma lista vazia; toda vez que laço é percorrido, anexamos o próximo elemento. Então, res é outro tipo de acumulador.

Uma operação como todas_maiúsculas é por vezes chamado de mapa pois “mapeia” uma função (neste caso uppercase) a cada um dos elementos em uma sequência.

Outro tipo comum de operação é selecionar alguns dos elementos de uma lista e retornar uma sublista. Por exemplo, a seguinte função recebe uma lista de strings e retorna uma lista que contém apenas as strings com letras maiúsculas:

function apenas_maiúsculas(t)
    res = []
    for s in t
        if s == uppercase(s)
            push!(res, s)
        end
    end
    res
end

Uma operação como apenas_maiúsculas é chamada de filtro pois seleciona alguns dos elementos e filtra outros.

Operações mais comuns de listas podem ser expressas como uma combinação de mapeamento, filtro e redução.

Sintaxe do Ponto

Para cada operador binário como ^, existe um operador ponto .^ correspondente que é automaticamente definido para efetuar ^ elemento-a-elemento em listas. Por exemplo, [1, 2, 3] ^ 3 não é definido, mas [1, 2, 3] .^ 3 é definido como calcular elemento a elemento o resultado [1^3, 2^3, 3^3]:

julia> print([1, 2, 3] .^ 3)
[1, 8, 27]

Qualquer função f do Julia pode ser aplicada elemento a elemento à qualquer lista com a sintaxe do ponto. Por exemplo, para deixar uma lista de strings com todas as strings em letra maiúscula, não precisamos explicitar o laço:

julia> t = uppercase.(["abc", "def", "ghi"]);

julia> print(t)
["ABC", "DEF", "GHI"]

Esse é um jeito elegante de criar mapeamentos. A função todas_maiúsculas pode ser implementada em uma única linha:

function todas_maiúsculas(t)
    uppercase.(t)
end

Deletando (Inserindo) Elementos

Existem várias maneiras de deletar elementos de uma lista. Se você sabe o índice do elemento que você precisa, você pode usar splice!:

julia> t = ['a', 'b', 'c'];

julia> splice!(t, 2)
'b': ASCII/Unicode U+0062 (category Ll: Letter, lowercase)
julia> print(t)
['a', 'c']

splice! modifica a lista e retorna o elemento que foi removido.

pop! deleta e retorna o último elemento:

julia> t = ['a', 'b', 'c'];

julia> pop!(t)
'c': ASCII/Unicode U+0063 (category Ll: Letter, lowercase)
julia> print(t)
['a', 'b']

popfirst! deleta e retorna o primeiro elemento:

julia> t = ['a', 'b', 'c'];

julia> popfirst!(t)
'a': ASCII/Unicode U+0061 (category Ll: Letter, lowercase)
julia> print(t)
['b', 'c']

As funções pushfirst! e push! inserem, respectivamente, um elemento no início e no fim de uma lista.

Se você não precisa do valor removido, você pode usar a função deleteat!:

julia> t = ['a', 'b', 'c'];

julia> print(deleteat!(t, 2))
['a', 'c']

A função insert! insere um elemento em um índice dado:

julia> t = ['a', 'b', 'c'];

julia> print(insert!(t, 2, 'x'))
['a', 'x', 'b', 'c']

Listas e Strings

Uma string é uma sequência de caracteres e uma lista é uma sequência de valores, mas uma lista de caracteres não é o mesmo que uma string. Para converter uma string em uma lista de caracteres, você pode usar a função collect:

julia> t = collect("spam");

julia> print(t)
['s', 'p', 'a', 'm']

A função collect divide uma sequência ou outra sequêcia em elementos individuais.

Se você quer dividir uma string em palavras, você pode usar a função split:

julia> t = split("vim lhe trazer este humilde presente");

julia> print(t)
SubString{String}["vim", "lhe", "trazer", "este", "humilde", "presente"]

Um argumento opcional chamado delimitador especifica quais caracteres devem ser usados como limites de palavras. Os seguintes exemplos usam um hífen como um delimitador:

julia> t = split("mayday-mayday-mayday", '-');

julia> print(t)
SubString{String}["mayday", "mayday", "mayday"]

join é o inverso de split. Ela recebe uma lista de strings e concatena os elementos:

julia> t = ["vim", "lhe", "trazer", "este", "humilde", "presente"];

julia> s = join(t, ' ')
"vim lhe trazer este humilde presente"

Neste caso o delimitador é um caractere de espaço. Para concatenar strings sem espaços, você não precisa especificar um delimitador.

Objetos e Valores

Um objeto é algo que uma variável pode se referir a. Até agora, você poderia usar “objeto” e “valor” sem distinção.

Se você executar estes comandos de atribuições:

a = "banana"
b = "banana"

Sabemos que ambas a e b referem-se a uma string, mas não sabemos se eles referem à mesma string. Existem dois estados possíveis, mostrados na Figura 10-2.

fig102
Figura 12. Diagrama de estado

Em um caso, a e b referem-se a dois objetos diferentes que possuem o mesmo valor. No segundo caso, elas referem-se ao mesmo objeto.

Para verificar se duas variáveis referem-se ao mesmo objeto, você pode usar o operador (\equiv TAB) ou ===.

julia> a = "banana"
"banana"
julia> b = "banana"
"banana"
julia> a ≡ b
true

Nesse exemplo, o Julia apenas criou um objeto string, e ambos a e b referem-se a ele. Mas quando você cria duas listas, você obtém dois objetos:

julia> a = [1, 2, 3];

julia> b = [1, 2, 3];

julia> a ≡ b
false

Portanto, o diagrama de estado se parece com Diagrama de estado.

fig103
Figura 13. Diagrama de estado

Nesse caso poderíamos dizer que as duas listas são equivalentes, porque possuem os mesmos elementos, mas não idênticos, porque elas não são o mesmo objeto. Se dois objetos são idênticos, eles também são equivalentes, mas se eles são equivalentes, eles não necessariamente são idênticos.

Para sermos mais precisos, um objeto possui um valor. Se você avaliar [1, 2, 3], você obterá um objeto lista cujo o valor é uma sequência de inteiros. Se uma outra lista possuir os mesmos elementos, dizemos que eles tem os mesmos valores, mas que não são o mesmo objeto.

Alias

Se a refere-se a um objeto e você atribuir b = a, então ambas variáveis irão se referir ao mesmo objeto:

julia> a = [1, 2, 3];

julia> b = a;

julia> b ≡ a
true

O diagrama de estado se parece com Diagrama de estado.

fig104
Figura 14. Diagrama de estado

A associação de uma variável com um objeto é chamado de referência. Nesse exemplo, existem duas referências ao mesmo objeto.

Um objeto com mais de uma referência contém mais de um nome, dizemos então que esse objeto é um alias.

Se um objeto alias for mutável, as alterações feitas com um alias afetam o outro:

julia> b[1] = 42
42
julia> print(a)
[42, 2, 3]
Atenção

Embora esse comportamento possa ser útil, está propenso a erros. Em geral, é mais seguro evitar alias quando você estiver trabalhando com objetos mutáveis.

Para objetos imutáveis, como strings, o alias não é um problema. Neste exemplo:

a = "banana"
b = "banana"

Quase nunca faz diferença se a e b referem-se a mesma string ou não.

Argumentos de Listas

Quando você passa uma lista para uma função, a função recebe uma referência para a lista. Se a função modifica a lista, quem chama nota a diferença. Por exemplo, deleta_cabeça! remove o primeiro elemento de uma lista:

function deleta_cabeça!(t)
    popfirst!(t)
end

Segue abaixo como isto é utilizado:

julia> letras = ['a', 'b', 'c'];

julia> deleta_cabeça!(letras);

julia> print(letras)
['b', 'c']

O parâmetro t e a variável letras são alias para o mesmo objeto. O diagrama de estado se parece com a Diagrama de estado.

fig105
Figura 15. Diagrama de estado

Já que a lista é compartilhada por dois quadros, eu a desenhei entre eles.

É importante distinguir operações que modificam listas de operações que criam novas listas. Por exemplo, push! modifica uma lista, mas vcat cria uma nova lista.

Aqui vai um exemplo usando push!:

julia> t1 = [1, 2];

julia> t2 = push!(t1, 3);

julia> print(t1)
[1, 2, 3]

t2 é um alias de t1.

E aqui, um exemplo de vcat:

julia> t3 = vcat(t1, [4]);

julia> print(t1)
[1, 2, 3]
julia> print(t3)
[1, 2, 3, 4]

O resultado de vcat é uma nova lista e a lista original permanece inalterada.

Essa diferença é importante quando você escreve funções que devem modificar listas.

Por exemplo, essa função não deleta a cabeça de uma lista:

function não_deleta_cabeça(t)
    t = t[2:end]                # ERRADO!
end

O operador de fatia cria uma nova lista e a atribuição faz com que t se refira a ela, mas isso não afeta quem a chama.

julia> t4 = não_deleta_cabeça(t3);

julia> print(t3)
[1, 2, 3, 4]
julia> print(t4)
[2, 3, 4]

No início de não_deleta_cabeça, t e t3 referem-se à mesma lista. No final, t refere-se a uma nova lista, mas t3 continua a referir-se à lista original, que não foi modificada.

Uma alternativa é escrever uma função que cria e retorna uma nova lista. Por exemplo, calda retorna todos, exceto o primeiro elemento de uma lista:

function calda(t)
    t[2:end]
end

Essa função deixa a lista original sem modificações. Veja como ela é usada:

julia> letras = ['a', 'b', 'c'];

julia> resto = calda(letras);

julia> print(resto)
['b', 'c']

Depuração

O uso descuidado de listas (e outros objetos mutáveis) pode levar longas horas para depurar. Veja algumas armadilhas comuns e maneiras de como evitá-las:

  • A maioria das funções de lista modifica o argumento. É o oposto das funções de strings, que retornam uma nova string e deixam a original em paz.

    Se você está acostumado a escrever código de string como:

    nova_palavra = strip(palavra)

    É tentador escrever código de lista assim:

    t2 = sort!(t1)

    Como sort! retorna a lista original modificada t1, t2 é um alias de t1.

    Dica

    Antes de usar funções e operadores de lista, você deve ler a documentação com cuidado e testá-los no modo interativo.

  • Escolha um idioma e fique com ele.

    Parte do problema com listas é que existem muitas maneiras de fazer as coisas. Por exemplo, para remover um elemento de uma lista, você pode usar pop!, popfirst!, delete_at ou até mesmo uma atribuição de fatia. Para adicionar um elemento, você pode usar push!, pushfirst!, insert! Ou vcat. Supondo que t é uma lista e x é um elemento da lista, os seguintes estão corretos:

    insert!(t, 4, x)
    push!(t, x)
    append!(t, [x])

    E os seguintes estão errados

    insert!(t, 4, [x])         # ERRADO!
    push!(t, [x])              # ERRADO!
  • Faça cópias para evitar alias.

    Se você quer usar uma função como sort! que modifica o argumento, mas você precisa manter a lista original, você pode fazer uma cópia:

    julia> t = [3, 1, 2];
    
    julia> t2 = t[:]; # t2 = copy(t)
    
    julia> sort!(t2);
    
    julia> print(t)
    [3, 1, 2]
    julia> print(t2)
    [1, 2, 3]

    Nesse exemplo, você também pode usar a função interna sort, que retorna uma nova lista ordenada e deixa a original em paz:

    julia> t2 = sort(t);
    
    julia> println(t)
    [3, 1, 2]
    julia> println(t2)
    [1, 2, 3]

Glossário

lista (array)

Uma sequência de valores.

elemento

Um dos valores de uma lista (ou outra sequência), também chamado de itens.

lista aninhada

Uma lista que é um elemento de uma outra lista.

acumulador

Uma variável que é utilizada em um laço para adicionar ou acumular resultados.

atribuição aumentada

Uma atribuição que atualiza o valor de uma variável utilizando um operador como =.

operador ponto

Um operador binário que é aplicado elemento a elemento de uma lista

sintaxe do ponto

Sintaxe utilizada para aplicar uma função elemento a elemento a qualquer lista.

operador de redução

Um padrão de processamento que percorre uma sequência e acumula os elementos em um único resultado.

mapa

Um padrão de processamento que percorre uma sequência e executa uma operação em cada elemento.

filtro

Um padrão de processamento que percorre uma sequência e seleciona os elementos que atendem a algum critério.

objeto

Algo que uma variável pode se referir. Um objeto tem um tipo e um valor.

equivalente

Contém o mesmo valor.

idêntico

Ser o mesmo objeto (o que implica equivalência).

referência

Associação entre uma variável e seu valor.

alias

Uma circunstância no qual duas ou mais variáveis referem-se ao mesmo objeto.

argumentos opcionais

Argumentos que não são obrigatórios.

delimitador

Um caractere ou string utilizada para indicar onde uma string deve ser cortada.

Exercícios

Exercício 10-1

Escreva uma função chamada soma_aninhada que recebe uma lista de listas de números inteiros e some os elementos de todas as listas aninhadas. Por exemplo:

julia> t = [[1, 2], [3], [4, 5, 6]];

julia> soma_aninhada(t)
21
Exercício 10-2

Escreva uma função chamada soma_cumulativa que recebe uma lista de números e retorne a soma cumulativa; isto é, uma nova lista em que o \(i\)-ésimo elemento é a soma do primeiro elemento \(i\) da lista original. Por exemplo:

julia> t = [1, 2, 3];

julia> print(soma_cumulativa(t))
Any[1, 3, 6]
Exercício 10-3

Escreva uma função chamada interior que recebe uma lista e retorna uma nova lista que não contém o primeiro e o último elemento. Por exemplo:

julia> t = [1, 2, 3, 4];

julia> print(interior(t))
[2, 3]
Exercício 10-4

Escreva uma função chamada interior! que recebe uma lista, modifique-a removendo o primeiro e o último elemento e retorne nothing. Por exemplo:

julia> t = [1, 2, 3, 4];

julia> interior!(t)

julia> print(t)
[2, 3]
Exercício 10-5

Escreva uma função chamada é_ordenada que use uma lista como parâmetro e retorne true se a lista estiver ordenada em ordem crescente e false caso contrário. Por exemplo:

julia> é_ordenada([1, 2, 2])
true
julia> é_ordenada(['b', 'a'])
false
Exercício 10-6

Duas palavras são anagramas se você puder reorganizar as letras de uma para formar a outra. Escreva uma função chamada é_anagrama que recebe duas strings e retorne true se elas forem anagramas.

Exercise 10-7

Escreva uma função chamada tem_duplicatas que recebe uma lista e retorne true se houver algum elemento que apareça mais de uma vez. Ela não deve modificar a lista original.

Exercício 10-8

Este exercício refere-se ao chamado Paradoxo de Aniversário, sobre o qual você pode ler em https://pt.wikipedia.org/wiki/Paradoxo_do_anivers%C3%A1rio.

Se houver 23 alunos em sua turma, quais são as chances de vocês dois terem o mesmo aniversário? Você pode estimar essa probabilidade gerando amostras aleatórias de 23 aniversários e verificando correspondências.

Dica

Você pode gerar aniversários aleatórios com rand(1:365).

Exercício 10-9

Escreva uma função que leia o arquivo words.txt e crie uma lista com um elemento por palavra. Escreva duas versões dessa função, uma usando push! e a outra usando o idioma t=[t ..., x]. Qual delas demora mais para ser executada? Por quê?

Exercício 10-10

Para verificar se uma palavra está na lista de palavras, você poderia usar o operador , mas isto seria lento porque as palavras seriam pesquisadas em ordem.

Como as palavras estão em ordem alfabética, podemos acelerar as coisas com uma busca por bissecção (também conhecida como busca binária), que é semelhante ao que você faz quando procura uma palavra no dicionário. Você começa no meio e verifica se a palavra que você procura vem antes da palavra no meio da lista. Nesse caso, você pesquisa a primeira metade da lista da mesma maneira. Caso contrário, você pesquisará a segunda metade.

De qualquer forma, você reduz pela metade o espaço restante da busca. Se a lista de palavras possuir 113.809 palavras, serão necessárias 17 etapas para encontrar a palavra ou concluir que ela não está na lista.

Escreva uma função chamada em_bisseção que usa uma lista ordenada e um valor-alvo e retorna true se a palavra estiver na lista e false se não estiver.

Exercício 10-11

Duas palavras são um “par reverso” se uma for o inverso da outra. Escreva um programa par_reverso que encontre todos os pares reversos na lista de palavras.

Exercício 10-12

Duas palavras “interligam” se receber letras alternadas de cada uma forma uma nova palavra. Por exemplo, “shoe” e “cold” se interligam para formar “schooled”.

Crédito: Este exercício é inspirado em um exemplo em http://puzzlers.org.

  1. Escreva um programa que encontre todos os pares de palavras que se interligam.

    Dica

    Não enumere todos os pares!

  2. Você consegue encontrar alguma palavra interligada/entrelaçada de três vias; isto é, toda terceira letra forma uma palavra, começando na primeira, segunda ou terceira?

11. Dicionários

Este capítulo apresenta outra estrutura de dados embutida chamada dicionário.

Um Dicionário é um Mapeamento

Um dicionário é como uma lista, mas mais geral. Em uma lista, os índices devem ser inteiros e em um dicionário, eles podem ser de (quase) qualquer tipo.

Um dicionário contém uma coleção de índices, que são chamados de chaves, e uma coleção de valores. Cada chave é associada a um único valor. A associação de uma chave e um valor é chamada de par chave-valor ou, às vezes, de item.

Na linguagem matemática, um dicionário representa um mapeamento das chaves para os valores, então você também pode dizer que cada chave “é mapeada” para um valor. Como um exemplo, iremos construir um dicionário que mapeia palavras em Português para palavras em Espanhol, com as chaves e os valores todos strings.

A função Dict cria um novo dicionário sem items. Como Dict é um nome de uma função embutida, devemos evitar usar ele como um nome de variável.

julia> pt_para_esp = Dict()
Dict{Any,Any} with 0 entries

O tipo do dicionário é cercado por chaves: as chaves são do tipo Any e os valores também são do tipo Any.

O dicionário está vazio. Para adicionar items no dicionário, podemos usar os colchetes:

julia> pt_para_esp["um"] = "uno";

Essa linha cria um item que mapeia a chave "um" para o valor "uno". Se imprimirmos o dicionário novamente, nós vemos o par chave-valor com a flecha => entre a chave e o valor:

julia> pt_para_esp
Dict{Any,Any} with 1 entry:
  "um" => "uno"

Essa formatação de saída também é uma formatação de entrada. Por exemplo, você pode criar um novo dicionário com três itens:

julia> pt_para_esp = Dict("um" => "uno", "dois" => "dos", "três" => "tres")
Dict{String,String} with 3 entries:
  "dois" => "dos"
  "três" => "tres"
  "um"   => "uno"

Todas as chaves e os valores iniciais são strings, então um Dict{String,String} é criado.

Atenção

A ordem do par chave-valor pode não ser a mesma. Se você digitar o mesmo exemplo no seu computador, pode obter um resultado diferente. Em geral, a ordem dos itens em um dicionário é imprevisível.

Mas isso não é um problema pois os elementos de um dicionário nunca são indexados com índices inteiros. Ao invés disso, nós usamos as chaves para consultar os valores correspondentes:

julia> pt_para_esp["dois"]
"dos"

A chave "dois" sempre é mapeada para o valor "dos", então a ordem dos itens não importa.

Se a chave não está no dicionário, nós recebemos uma exceção:

julia> pt_para_esp["quatro"]
ERROR: KeyError: key "quatro" not found

A função length funciona nos dicionários e retorna o número de pares chave-valor:

julia> length(pt_para_esp)
3

A função keys retorna uma lista com as chaves do dicionário:

julia> ks = keys(pt_para_esp);

julia> print(ks)
["dois", "três", "um"]

Agora você pode usar o operador para verificar se algo aparece como uma chave no dicionário:

julia> "um" ∈ ks
true
julia> "uno" ∈ ks
false

Para verificar se algo aparece como um valor em um dicionário, você pode usar a função values que retorna uma coleção de valores, e em seguida usar o operador :

julia> vs = values(pt_para_esp);

julia> "uno" ∈ vs
true

O operador utiliza algoritmos diferentes para as listas e dicionários. Para as listas, ele busca os elementos da lista em ordem, como em Buscando. À medida que a lista fica maior, o tempo de busca cresce diretamente proporcional.

Para os dicionários, o Julia usa um algoritmo chamado tabela hash (ou tabela de dispersão ou tabela de espalhamento) que possui uma propriedade excepcional: o operador leva aproximadamente o mesmo tempo independente da quantidade de itens do dicionário.

Dicionários como uma Coleção de Contadores

Suponha que é dado a você uma string e você gostaria de contar quantas vezes cada letra aparece. Existem muitas maneiras de fazer isso:

  • Você poderia criar 26 variáveis, um para cada letra do alfabeto. Em seguida você poderia percorrer a string, e para cada caractere, incrementar o contador correspondente, provavelmente usando um condicional encadeado.

  • Você poderia criar uma lista com 26 elementos. Em seguida pode-se converter cada caractere para um número (usando a função embutida Int), usar o número como um índice para a lista, e incrementar o contador adequado.

  • Você poderia criar um dicionário com os caracteres como as chaves e os contadores como seus valores correspondentes. A primeira vez que você vê um caractere, adicionaria um item no dicionário. Após isso, você só incrementaria o valor de um item existente.

Cada uma dessas opções efetua o mesmo cálculo, mas cada uma delas implementa este cálculo de formas diferentes.

Uma implementação é uma maneira de efetuar um cálculo e algumas implementações são melhores que outras. Por exemplo, uma vantagem da implementação do dicionário é que nós não temos que saber antecipadamente quais letras aparecem na string, e sim criar espaço só para as letras que aparecem.

O código pode parecer com algo como:

function histograma(s)
    d = Dict()
    for c in s
        if c ∉ keys(d)
            d[c] = 1
        else
            d[c] += 1
        end
    end
    d
end

O nome da função é histograma, que é um termo estátistico para uma coleção de contadores (ou frequências).

A primeira linha da função cria um dicionário vazio. O laço for percorre a string. Toda vez que o laço é percorrido, se o caractere c não está no dicionário, nós criamos um novo item com a chave c e o valor inicial 1 (já que nós vimos esta letra uma vez). Se c já está no dicionário, nós incrementamos d[c].

Funciona da seguinte forma:

julia> h = histograma("brontossauro")
Dict{Any,Any} with 8 entries:
  'n' => 1
  's' => 2
  'a' => 1
  'r' => 2
  't' => 1
  'o' => 3
  'u' => 1
  'b' => 1

O histograma indica que as letras a e b aparecem uma vez; o aparece três, e assim em diante.

Dicionários possuem uma função chamada get que recebe uma chave e um valor padrão. Se a chave aparece no dicionário, get retorna o valor correspondente; caso contrário ela retorna o valor padrão. Por exemplo:

julia> h = histograma("a")
Dict{Any,Any} with 1 entry:
  'a' => 1
julia> get(h, 'a', 0)
1
julia> get(h, 'b', 0)
0
Exercício 11-1

Use get para escrever histograma de uma maneira mais concisa. Você deve ser capaz de eliminar a declaração if.

Laços e Dicionários

Você pode percorrer as chaves de um dicionário em uma declaração for. Por exemplo, imprime_hist exibe cada chave e o seu valor correspondente:

function imprime_hist(h)
    for c in keys(h)
        println(c, " ", h[c])
    end
end

Aqui está o resultado:

julia> h = histograma("papagaio");

julia> imprime_hist(h)
g 1
a 3
i 1
p 2
o 1

Novamente as chaves não estão em nenhuma ordem específica. Para percorrer as chaves em ordem, você pode combinar sort e collect:

julia> for c in sort(collect(keys(h)))
           println(c, " ", h[c])
       end
a 3
g 1
i 1
o 1
p 2

Consulta Inversa

Dado um dicionário d e uma chave k, é fácil achar o valor correspondente v = d[k]. Esta operação é chamada de consulta.

Mas e se você tem v e quer achar k? Você tem dois problemas: primeiro, pode haver mais de uma chave que mapeia para o valor v. Dependendo do que é pedido, você poderia escolher um, ou teria que criar uma lista que contém todos eles. Segundo, não há uma sintaxe simples que faz uma consulta inversa; você tem que procurar.

Aqui está uma função que recebe um valor e retorna a primeira chave que mapeia a este valor:

function consulta_inversa(d, v)
    for k in keys(d)
        if d[k] == v
            return k
        end
    end
    error("Erro de Consulta")
end

Esta função é mais um exemplo do padrão de busca, mas usa uma função que ainda não vimos, error. A função error é usada para gerar um ErrorException que interrompe o fluxo normal de controle. Neste caso ela tem a mensagem "Erro de Consulta", indicando que a chave não existe.

Se nós chegarmos no final do laço, isso significa que v não aparece no dicionário como um valor, então geramos uma exceção.

Aqui está um exemplo de uma consulta inversa bem-sucedida:

julia> h = histograma("papagaio");

julia> chave = consulta_inversa(h, 2)
'p': ASCII/Unicode U+0070 (category Ll: Letter, lowercase)

E uma malsucedida:

julia> chave = consulta_inversa(h, 4)
ERROR: Erro de Consulta

O resultado de uma exceção gerada é o mesmo quando o Julia gera um: ele exibe o stacktrace e uma mensagem de erro.

Julia fornece uma maneira otimizada de fazer uma consulta inversa: findall(isequal(3), h).

Atenção

Uma consulta inversa é muito mais demorada que uma consulta normal; se você tiver que executá-la várias vezes, ou se o dicionário ficar muito grande, o desempenho do seu programa diminuirá.

Dicionários e Listas

Listas podem aparecer como valores em um dicionário. Por exemplo, se você receber um dicionário que mapeia letras às frequências, você pode querer invertê-lo; isto é, criar um dicionário que mapeia frequências até as letras. Já que várias letras podem ter a mesma frequência, cada valor em um dicionário invertido deve ser uma lista de letras.

Aqui está uma função que inverte um dicionário:

function inverte_dict(d)
    inverso = Dict()
    for chave in keys(d)
        valor = d[chave]
        if valor ∉ keys(inverso)
            inverso[valor] = [chave]
        else
            push!(inverso[valor], chave)
        end
    end
    inverso
end

Cada vez que o laço é percorrido, chave recebe uma chave de d e valor recebe o valor correspondente. Se valor não está em inverso, isto significa que não a vimos ainda, então criamos um novo item e inicializamos com um singleton (uma lista que contém um único elemento). Caso contrário esse valor já foi visto, e então acrescentamos a chave correspondente à lista.

Aqui está um exemplo:

julia> hist = histograma("papagaio");

julia> inverso = inverte_dict(hist)
Dict{Any,Any} with 3 entries:
  2 => ['p']
  3 => ['a']
  1 => ['g', 'i', 'o']
fig111
Figura 16. Diagrama de Estado

Diagrama de Estado é um diagrama de estado mostrando hist e inverso. Um dicionário é representado como uma caixa com os pares chave-valor dentro. Para os valores que são inteiros, pontos flutuantes ou strings, eu os desenho dentro da caixa, já para as listas normalmente desenho fora da caixa, só para simplificar o diagrama.

Nota

Mencionamos anteriormente que um dicionário é implementado usando uma tabela hash e isso significa que as chaves devem ser hashable, isto é, de um tipo que permite que uma função hash atuem sobre elas.

Uma hash é uma função que recebe um valor (de qualquer tipo) e retorna um inteiro. Dicionários usam estes inteiros, chamados de valores hash, para guardar e consultar pares chave-valor.

Memos

Se você já brincou com a função fibonacci de [one_more_exemple], pode ter percebido que quanto maior o argumento que você fornece, mais tempo a função leva para executar. Além disso, o tempo de execução cresce rapidamente.

Para entender o porquê, considere Grafo de chamada, que mostra um grafo de chamada para fibonacci com n = 4:

fig112
Figura 17. Grafo de chamada

Um grafo de chamada mostra um conjunto de quadros da função, com linhas conectando cada quadro aos quadros que a função chama. No topo do grafo, fibonacci com n = 4 chama fibonacci com n = 3 e n = 2. Por sua vez, fibonacci com n = 3 chama fibonacci com n = 2 e n = 1. E assim em diante.

Conte quantas vezes fibonacci(0) e fibonacci(1) são chamadas. Está é uma solução ineficiente do problema, e fica pior à medida que o argumento aumenta.

Uma solução é acompanhar os valores já calculados armazenando-os em um dicionário. Um valor previamente calculado que é armazenado para uso posterior é chamado de memo. Aqui está uma versão “memoizada” de fibonacci:

conhecidos = Dict(0=>0, 1=>1)

function fibonacci(n)
    if n ∈ keys(conhecidos)
        return conhecidos[n]
    end
    res = fibonacci(n-1) + fibonacci(n-2)
    conhecidos[n] = res
    res
end

conhecidos é um dicionáro que guarda os números de Fibonacci que já sabemos. Ele começa com dois itens: 0 mapeia para 0 e 1 mapeia para 1.

Toda vez que fibonacci é chamada, ela checa conhecidos. Se o resultado já está lá, ela retorna imediatamente. Por outro lado ela tem que computar um novo valor, adicionar ele ao dicionário, e retorná-lo.

Se você executar esta versão de fibonacci e comparar com a original, você verá que a atual é muito mais rápida.

Variáveis Globais

No exemplo anterior, conhecidos é criado fora da função, então ela pertence ao quadro especial chamado Main. Variáveis em Main são às vezes chamadas de globais pois podem ser acessadas de qualquer função. Diferente de variáveis locais, que desaparecem quando a função acaba, variáveis globais persistem de uma chamada de função para a próxima.

É comum usar variáveis globais para flags; isto é, variáveis booleanas que indicam (“sinalizam”) se uma condição é verdadeira. Por exemplo, alguns programas usam uma flag chamada verbose para controlar o nível de detalhamento na saída:

verbose = true

function exemplo1()
    if verbose
        println("Executando exemplo1")
    end
end

Se você tentar reatribuir uma variável global, poderá se surpreender. O exemplo a seguir deve acompanhar se a função foi chamada:

foi_chamada = false

function exemplo2()
    foi_chamada = true         # ERRADO
end

Mas se você executar a função, você verá que o valor de foi_chamada não muda. O problema é que exemplo2 cria uma nova variável local denominada foi_chamada. A variável local é removida quando a função termina, e não tem nenhum efeito sobre a variável global.

Para reatribuir uma variável global dentro de uma função, você deve declarar a variável global antes de usá-la:

foi_chamada = false

function exemplo2()
    global foi_chamada
    foi_chamada = true
end

A declaração global indica ao interpretador algo como “Nesta função, quando eu digo foi_chamada, eu quero dizer a variável global; não crie uma local.”

Aqui está um exemplo que tenta atualizar uma variável global:

conta = 0

function exemplo3()
    conta = conta + 1          # ERRADO
end

Ao executar a função, você recebe:

julia> exemplo3()
ERROR: UndefVarError: conta not defined

O Julia assume que conta é local, partindo da suposição de que você está lendo a função antes de escrevê-la. A solução, novamente, é declarar conta como global.

conta = 0

function exemplo3()
    global conta
    conta += 1
end

Se uma variável global se refere a um valor mutável, você pode modificar o valor sem declarar a variável global:

conhecido = Dict(0=>0, 1=>1)

function exemplo4()
    conhecido[2] = 1
end

Então você pode adicionar, remover e substituir os elementos de uma lista global ou dicionário, mas se você quiser reatribuir a variável, você deve declará-la como global:

conhecido = Dict(0=>0, 1=>1)

function exemplo5()
    global conhecido
    conhecido = Dict()
end

Por razões de performance, deve-se declarar uma variável global como constante. Você já não pode reatribuir a variável mas caso ela faça referência a um valor mutável, pode-se modificar o valor.

const conhecido = Dict(0=>0, 1=>1)

function exemplo4()
    conhecido[2] = 1
end
Atenção

Variáveis globais podem ser utéis, mas se tem muitas delas, e você modifica-as frequentemente, elas podem ser a causa dos programas serem dificéis de depurar e terem mau desempenho.

Depuração

Na medida em que você trabalha com conjuntos de dados maiores, pode ser que seja difícil depurar imprimindo e checando a saída na mão. Aqui estão algumas sugestões para depurar conjuntos de dados maiores:

  • Diminua a entrada:

    Se possível, reduza o tamanho do conjunto de dados. Por exemplo, se o programa lê um arquivo de texto, comece com apenas as 10 primeiras linhas, ou com o menor exemplo que dá erro. Você não deve editar os arquivos em si, mas modificar o programa para que ele leia somente as primeiras \(n\) linhas.

    Se existe um erro, você pode reduzir de \(n\) para o menor valor que dá erro, e em seguida incrementá-lo gradualmente à medida que você encontra e corrige os erros.

  • Verifique os resumos e os tipos:

    Ao invés de imprimir e checar o conjunto de dados inteiro, considere imprimir os resumos dos dados: por exemplo, o número de itens em um dicionário ou o total de uma lista de números.

    Uma causa comum de erros de execução é um valor que não é do tipo correto. Para a depuração desse tipo de erro, geralmente é suficiente imprimir o tipo de um valor.

  • Escreva auto-verificações:

    Algumas vezes você pode escrever código para checar os erros automaticamente. Por exemplo, se você está calculando a média de uma lista de números, você poderia checar que o resultado não está acima do maior elemento da lista ou abaixo do menor elemento. Isso é chamado de “verificação de sanidade”.

    Outro tipo de verificação compara o resultado de dois cálculos diferentes para verificar se eles são consistentes. Isso é chamado de “verificação de consistência”.

  • Formate a saída:

    Resultados de depuração formatados podem facilitar a detecção de erros, como visto em um exemplo em Depuração.

    E mais uma vez, o tempo que você usa construindo andaimes pode reduzir o tempo gasto na depuração.

Glossário

mapeamento

Uma relação na qual cada elemento de um conjunto corresponde a um elemento de outro conjunto.

dicionário

Um mapeamento de chaves para os seus valores correspondentes.

par chave-valor

A representação de um mapeamento de uma chave para um valor.

item

Em um dicionário, é outro nome para o par chave-valor.

chave

Um objeto que aparece em um dicionário como a primeira parte de um par chave-valor.

valor

Um objeto que aparece em um dicionário como a segunda parte de um par chave-valor. Isso é mais específico que o nosso uso prévio da palavra “valor”.

implementação

Uma maneira de efetuar os cálculos.

tabela hash

O algoritmo usado para implementar os dicionários em Julia.

função hash

Uma função usado por uma tabela hash para computar a localização de uma chave.

hashable

Um tipo que tem uma função hash.

consulta

Uma operação em um dicionário que recebe uma chave e encontra o valor correspondente.

consulta inversa

Uma operação em um dicionário que recebe um valor e encontra uma ou mais chaves mapeadas para ele.

singleton

Uma lista (ou outra sequência) com um único elemento.

grafo de chamada

Um diagrama que mostra todo quadro criado durante a execução de um programa, com uma flecha que vai de quem chama para quem é chamado.

memo

Um valor já computado e guardado para evitar cálculos futuros desnecessários.

variável global

Uma variável definida fora da função. Variáveis globais podem ser acessadas de qualquer função.

declaração global

Uma declaração para tornar um nome de variável como global.

flag

Uma variável booleana usada para indicar se uma condição é verdadeira.

declaração

Uma declaração como global que informa ao interpretador algo sobre a variável.

variável global constante

Uma variável global que não pode ser reatribuída.

Exercícios

Exercício 11-2

Escreva uma função que leia as palavras em palavras.txt e guarde-as como chaves em um dicionário. Não importa quais sejam os valores. Em seguida, você pode usar o operador como uma maneira rápida de verificar se uma string está ou não no dicionário.

Se o exercício Exercício 10-10 foi feito, você pode comparar a velocidade desta implementação com o operador em listas e na busca em bissecção.

Exercício 11-3

Leia a documentação da função de dicionário get! e use-a para escrever uma versão mais concisa de inverte_dict.

Exercício 11-4

Memoize a função de Ackermann de Exercício 6-5 e verifique se a memoização possibilita a avaliação da função com argumentos maiores.

Exercício 11-5

Se Exercise 10-7 foi feito, então já possui uma função chamada tem_duplas que recebe uma lista como parâmetro e retorna true se há qualquer objeto que aparece mais de uma vez na lista.

Use um dicionário para escrever uma versão mais rápida e simplificada de tem_duplas.

Exercício 11-6

Duas palavras são “pares rotacionados” se você pode rotacionar um deles e obter o outro (ver rotaciona_palavra em Exercício 8-11).

Escreva um programa que lê uma lista e encontra todos os pares rotacionados.

Exercício 11-7

Aqui está outro quebra cabeça de Car Talk (https://www.cartalk.com/puzzler/browse):

Essa foi enviada por um sujeito chamado Dan O’Leary. Ele encontrou recentemente uma palavra em inglês comum de uma sílaba e cinco letras, que possui a seguinte propriedade peculiar. Quando você remove a primeira letra, as letras restantes formam um homófono da palavra original, isto é, uma palavra que soa exatamente igual. Troque a primeira letra, isto é, coloque-a novamente e remova a segunda letra, e o resultado é outro homófono da palavra original. E a pergunta é, qual é a palavra?

Agora irei dar um exemplo que não funciona. Vamos olhar para uma palavra de cinco letras, ‘wrack.’ W-R-A-C-K, como na expressão ‘wrack with pain.’ Se eu remover a primeira letra, tenho uma palavra de quatro letras, ’R-A-C-K.’ Como em, ‘Holy cow, did you see the rack on that buck! It must have been a nine-pointer!’ É um homófono perfeito. Se você colocar o ‘w’ novamente, e remover o ‘r’, você fica com a palavra ‘wack,’ que é uma palavra real, só não é um homófona das outras duas palavras.

Mas há pelo menos uma palavra, que Dan e nós conhecemos, que irá produzir dois homófonos se você remover tanto as duas primeiras letras para criar duas novas palavras de quatro letras. A pergunta é, qual é a palavra?

Você pode usar o dicionário de Exercício 11-2 para verificar se uma string está na lista de palavras.

Dica

Para verificar se duas palavras em inglês são homófonas, você pode usar o Dicionário CMU de Pronunciação. E também pode baixá-lo em http://www.speech.cs.cmu.edu/cgi-bin/cmudict.

Escreva um programa que lista todas as palavras que resolvem o quebra cabeça.

12. Tuplas

Este capítulo apresenta mais um tipo interno, a tupla, e mostra como as listas, os dicionários e as tuplas trabalham juntos. Também apresento um recurso útil para as listas de argumentos de tamanho variável, operadores de agrupamento e de separação.

Tuplas são Imutáveis

Uma tupla é uma sequência de valores. Estes valores podem ser de qualquer tipo e são indexados por números inteiros; logo, neste aspecto, as tuplas são muito similares às listas. A diferença importante é que as tuplas são imutáveis e que cada elemento pode ter seu próprio tipo.

Sintaticamente, uma tupla é uma lista de valores separados por vírgula:

julia> t = 'a', 'b', 'c', 'd', 'e'
('a', 'b', 'c', 'd', 'e')

Apesar de não ser necessário, é comum colocar tuplas entre parênteses:

julia> t = ('a', 'b', 'c', 'd', 'e')
('a', 'b', 'c', 'd', 'e')

Para criar uma tupla com um único elemento, tem que inserir uma vírgula no final:

julia> t1 = ('a',)
('a',)
julia> typeof(t1)
Tuple{Char}
Atenção

Um único valor entre parênteses sem vírgula não é uma tupla:

julia> t2 = ('a')
'a': ASCII/Unicode U+0061 (category Ll: Letter, lowercase)
julia> typeof(t2)
Char

Outra maneira de criar uma tupla é por meio da função interna tuple. Sem nenhum argumento, uma tupla vazia é criada:

julia> tuple()
()

Se vários argumentos são fornecidos, o resultado é uma tupla com os argumentos dados:

julia> t3 = tuple(1, 'a', pi)
(1, 'a', π)

Já que tuple é o nome de uma função interna, deve-se evitar usá-lo como o nome de variável.

A maioria dos operadores da lista também funciona com as tuplas. O operador colchete indexa um elemento:

julia> t = ('a', 'b', 'c', 'd', 'e');

julia> t[1]
'a': ASCII/Unicode U+0061 (category Ll: Letter, lowercase)

E o operador de fatia seleciona uma faixa de elementos:

julia> t[2:4]
('b', 'c', 'd')

E caso você tente modificar um dos elementos da tupla, uma mensagem de erro aparecerá:

julia> t[1] = 'A'
ERROR: MethodError: no method matching setindex!(::NTuple{5,Char}, ::Char, ::Int64)

Já que as tuplas são imutáveis, você não pode modificar os elementos.

Os operadores relacionais trabalham com tuplas e outras sequências; em Julia, começa-se comparando o primeiro elemento de cada sequência. Se forem iguais, passa-se para os elementos seguintes, e assim por diante, até encontrar elementos que diferem. Após isso, os elementos seguintes são desconsiderados (ainda que sejam realmente grandes).

julia> (0, 1, 2) < (0, 3, 4)
true
julia> (0, 1, 2000000) < (0, 3, 4)
true

Atribuição de Tuplas

Muitas vezes, deseja-se permutar os valores de duas variáveis. Nas atribuições convencionais, você tem que usar uma variável temporária. Por exemplo, para permutar as variáveis a e b:

temp = a
a = b
b = temp

Essa solução é trabalhosa; já a atribuição da tupla é mais elegante:

a, b = b, a

O lado esquerdo é uma tupla de variáveis; o lado direito é uma tupla de expressões. Cada valor é atribuído à sua correspondente variável. Todas as expressões no lado direito são avaliadas antes de qualquer uma das atribuições.

O número de variáveis do lado esquerdo não deve ser maior do que o número de valores do lado direito:

julia> (a, b) = (1, 2, 3)
(1, 2, 3)
julia> a, b, c = 1, 2
ERROR: BoundsError: attempt to access (1, 2)
  at index [3]

Geralmente, o lado direito pode ser qualquer tipo de sequência (string, lista ou tupla). Por exemplo, ao dividir um endereço de e-mail em um nome de usuário e um domínio, poderia-se escrever:

julia> email = "julio.cesar@roma"
"julio.cesar@roma"
julia> nome_usuário, domínio = split(email, '@');

O valor de retorno de split é uma lista com dois elementos; o primeiro elemento é atribuído a nome_usuário e o segundo a domínio.

julia> nome_usuário
"julio.cesar"
julia> domínio
"roma"

Tuplas como Valores de Retorno

A princípio, uma função pode retornar apenas um valor, mas se o valor for uma tupla, é como se devolvesse vários valores. Por exemplo, se você deseja dividir dois números inteiros e calcular o quociente e o restante, é ineficiente calcular x ÷ y e depois x % y. É melhor fazer os dois cálculos ao mesmo tempo.

A função embutida divrem recebe dois argumentos e retorna uma tupla de dois valores: o quociente e o restante, respectivamente. O resultado pode ser armazenado como uma tupla:

julia> t = divrem(7, 3)
(2, 1)

Ou use a atribuição de tupla para armazenar os elementos separadamente:

julia> q, r = divrem(7, 3);

julia> @show q r;
q = 2
r = 1

Eis um exemplo de uma função que retorna uma tupla:

function minmax(t)
    minimum(t), maximum(t)
end

As funções internas maximum e minimum encontram o maior e o menor elemento de uma sequência. minmax calcula os dois e retorna-os por meio de uma tupla. Já a função interna extrema é mais eficiente.

Tuplas com Argumentos de Comprimento Variável

As funções podem receber um número variável de argumentos. Um nome de parâmetro que termina com ... agrupa argumentos em uma tupla. Por exemplo, printall pega qualquer número de argumentos e os imprime:

function printall(args...)
    println(args)
end

O parâmetro de agrupamento pode ter qualquer nome que você goste, mas args é padronizado. Veja como funciona a função:

julia> printall(1, 2.0, '3')
(1, 2.0, '3')

O complemento do agrupamento é a separação. Se você tem uma seqüência de valores e quiser passá-la para uma função com diversos argumentos, pode-se usar o operador .... No exemplo seguinte, divrem recebe exatamente dois argumentos e não funciona com uma tupla:

julia> t = (7, 3);

julia> divrem(t)
ERROR: MethodError: no method matching divrem(::Tuple{Int64,Int64})

Mas se você separar a tupla, o comando funcionará:

julia> divrem(t...)
(2, 1)

Muitas das funções embutidas usam tuplas com argumentos de comprimento variável. Por exemplo, max e min podem receber qualquer número de argumentos:

julia> max(1, 2, 3)
3

Mas sum, não:

julia> sum(1, 2, 3)
ERROR: MethodError: no method matching sum(::Int64, ::Int64, ::Int64)
Exercício 12-1

Escreva uma função chamada soma_tudo que recebe qualquer número de argumentos e devolve o resultado da soma deles.

No mundo de Julia, agrupar é chamado de "slurp" e separar de "splat".

Listas e Tuplas

zip é uma função interna que recebe duas ou mais sequências e retorna uma coleção de tuplas em que cada tupla contém um elemento de cada sequência. O nome da função refere-se a um zíper, que une e intercala duas faixas de dentes.

Este exemplo intercala uma string com uma lista:

julia> s = "abc";

julia> t = [1, 2, 3];

julia> zip(s, t)
Base.Iterators.Zip{Tuple{String,Array{Int64,1}}}(("abc", [1, 2, 3]))

O resultado é um objeto zip que sabe como iterar através dos pares. O uso mais comum de zip ocorre em um laço for:

julia> for par in zip(s, t)
           println(par)
       end
('a', 1)
('b', 2)
('c', 3)

O objeto zip é um tipo de iterador, um objeto usado para percorrer uma sequência. De uma certa forma, os iteradores são similares às listas, e o que difere das listas é que não se pode usar um índice para selecionar um elemento a partir de um iterador.

Se você quiser usar operadores e funções de listas, pode-se usar um objeto zip para gerar uma lista:

julia> collect(zip(s, t))
3-element Array{Tuple{Char,Int64},1}:
 ('a', 1)
 ('b', 2)
 ('c', 3)

O resultado é uma lista de tuplas; e neste exemplo, cada tupla contém um caractere da string e o elemento correspondente da lista.

Se as sequências não tiverem o mesmo comprimento, o resultado terá o comprimento da menor sequência.

julia> collect(zip("Anna", "Rui"))
3-element Array{Tuple{Char,Char},1}:
 ('A', 'R')
 ('n', 'u')
 ('n', 'i')

Você pode usar a atribuição de tupla em um laço for para percorrer uma lista de tupla:

julia> t = [('a', 1), ('b', 2), ('c', 3)];

julia> for (letra, número) in t
           println(número, " ", letra)
       end
1 a
2 b
3 c

A cada iteração do laço, o Julia seleciona a próxima tupla na lista e atribui os elementos à letra e ao número. Os parênteses em torno de (letra, número) são necessários.

Se você combinar zip, for e a atribuição de tuplas, obtém-se uma função prática para analisar duas (ou mais) seqüências ao mesmo tempo. Por exemplo, tem_combinação considera duas seqüências, t1 e t2, e devolve true se existir um índice i tal que t1[i] == t2[i]:

function tem_combinação(t1, t2)
    for (x, y) in zip(t1, t2)
        if x == y
            return true
        end
    end
    false
end

Se você precisa percorrer os elementos de uma sequência e os seus índices, pode-se usar a função interna enumerate:

julia> for (índice, elemento) in enumerate("abc")
           println(índice, " ", elemento)
       end
1 a
2 b
3 c

O resultado de enumerate é um objeto enumerado, que itera sobre uma seqüência de pares onde cada par contém um índice (a partir de 1) e um elemento da seqüência dada.

Dicionários e Tuplas

Os dicionários podem ser usados como iteradores dos pares chave-valor. Você pode usá-lo em um laço for como este:

julia> d = Dict('a'=>1, 'b'=>2, 'c'=>3);

julia> for (chave, valor) in d
           println(chave, " ", valor)
       end
a 1
c 3
b 2

Como é de se esperar de um dicionário, os itens não estão em nenhuma específica ordem.

Indo na outra direção, você pode usar uma lista de tuplas para inicializar um novo dicionário:

julia> t = [('a', 1), ('c', 3), ('b', 2)];

julia> d = Dict(t)
Dict{Char,Int64} with 3 entries:
  'a' => 1
  'c' => 3
  'b' => 2

A combinação de Dict com zip resulta numa maneira concisa de criar um dicionário:

julia> d = Dict(zip("abc", 1:3))
Dict{Char,Int64} with 3 entries:
  'a' => 1
  'c' => 3
  'b' => 2

É comum utilizar tuplas como chaves nos dicionários. Por exemplo, uma lista telefônica pode mapear os pares de sobrenome e nome até os números de telefone. Supondo que definimos sobrenome, nome e número, poderíamos escrever:

diretório[sobrenome, nome] = número

A expressão entre parênteses é uma tupla. Poderíamos usar a atribuição de tuplas para percorrer este dicionário.

for ((sobrenome, nome), número) in diretório
    println(nome, " ", sobrenome, " ", número)
end

Este laço percorre os pares chave-valor em diretório, que são tuplas. Ele atribui os elementos da chave em cada tupla sobrenome e nome ao valor em número, e então imprime o nome completo e o número de telefone correspondente.

Existem duas maneiras de representar tuplas em um diagrama de estados. A versão mais detalhada mostra os índices e os elementos exatamente como eles aparecem em uma lista. Por exemplo, a tupla ("Carlos", "João") apareceria como no Diagrama de estado.

fig121
Figura 18. Diagrama de estado

Mas em um diagrama maior, você pode ocultar os detalhes. Por exemplo, um diagrama da lista telefônica pode ser impresso como em Diagrama de estado.

fig122
Figura 19. Diagrama de estado

Aqui, as tuplas são mostradas com a sintaxe do Julia para simplificar o diagrama. O número de telefone no diagrama é a linha de reclamações da BBC; sendo assim, não ligue para lá.

Sequências de Sequências

Temos focado nas listas de tuplas, mas quase todos os exemplos neste capítulo também funcionam com as listas de listas, tuplas de tuplas, e tuplas de listas. Para evitar enumerar as possíveis combinações, às vezes é mais fácil falar sobre seqüências de seqüências.

Em muitos contextos, os diferentes tipos de sequências (strings, listas e tuplas) podem ser usados de forma intercambiável. Então, como você deve escolher um ao invés dos outros?

Para começar com o óbvio, as strings são mais limitadas que as outras sequências porque os elementos precisam ser caracteres, além de serem imutáveis. Se você possivelmente precisar mudar os caracteres de uma string (ao invés de criar uma nova string), pode ser melhor usar uma lista de caracteres em seu lugar.

As listas são mais comuns que tuplas, principalmente porque são mutáveis. Mas existem algumas situações em que você pode preferir as tuplas:

  • Em algumas circunstâncias, como uma declaração return, sintaticamente é mais simples criar uma tupla do que uma lista.

  • Se você estiver passando uma sequência como argumento para uma função, o uso de tuplas reduz potencialmente o comportamento inesperado causado por um alias.

  • Por motivos de desempenho. O compilador pode ser especializado neste tipo.

Como as tuplas são imutáveis, elas não dispõem de funções como sort! e reverse!, que modificam os arrays existentes. Mas o Julia possui a função interna sort, que recebe uma lista e devolve uma nova lista com os mesmos elementos na ordem classificada, e reverse, que recebe qualquer sequência e devolve uma sequência do mesmo tipo na ordem contrária.

Depuração

As listas, os dicionários e as tuplas são exemplos de estruturas de dados; neste capítulo, começamos a ver estruturas de dados compostas, como arrays de tuplas ou dicionários que contêm tuplas como chaves e arrays como valores. As estruturas de dados compostas são práticas, embora sejam propensas ao que chamamos de erros de forma; isto é, erros causados ​​quando uma estrutura de dados tem o tipo, tamanho ou estrutura incorreta. Por exemplo, se você está esperando uma lista com um número inteiro e for fornecido um número inteiro (que não é um lista), não funcionará.

Julia permite anexar um tipo aos elementos de uma sequência. As informações de como isso é feito está em Despacho Múltiplo. A especificação do tipo elimina muitos erros de forma.

Glossário

tupla

Uma sequência imutável de elementos onde cada elemento pode ter seu próprio tipo.

atribuição de tupla

Uma atribuição com uma sequência no lado direito e uma tupla de variáveis no lado esquerdo. O lado direito é avaliado para que seus elementos sejam atribuídos às variáveis do lado esquerdo.

agrupamento

A operação de geração de uma tupla com argumentos de comprimento variável.

separação

A operação de tratamento de uma sequência como uma lista de argumentos.

objeto zip

O resultado da chamada de uma função interna zip; um objeto que itera através de uma sequência de tuplas.

iterador

Um objeto que pode iterar por uma sequência, mas que não dispõe de operadores e funções de lista.

estrutura de dados

Uma coleção de valores relacionados, frequentemente estruturados em lista, dicionários, tuplas, etc.

erro de forma

Um erro causado quando um valor tem a forma errada; ou seja, o tipo ou tamanho errado.

Exercícios

Exercício 12-2

Escreva uma função chamada mais_frequente que recebe uma string e imprime as letras em ordem decrescente de frequência. Procure amostras de texto de diversos idiomas diferentes e verifique como a frequência das letras varia entre os idiomas. Compare seus resultados com as tabelas em https://pt.wikipedia.org/wiki/Frequência_de_letras.

Exercício 12-3

Mais anagramas!

  1. Escreva um programa que leia uma lista de palavras de um arquivo (veja Lendo Listas de Palavras) e mostre todos os conjuntos de palavras que são anagramas.

    Aqui está um exemplo de como pode ser a saída:

    ["deltas", "desalt", "lasted", "salted", "slated", "staled"]
    ["retainers", "ternaries"]
    ["generating", "greatening"]
    ["resmelts", "smelters", "termless"]
    Dica

    Você pode querer construir um dicionário que mapeia uma coleção de letras até uma lista de palavras que podem ser soletradas com essas letras. A questão é: como você pode representar a coleção de letras de uma forma que ela possa ser usada como chave?

  2. Modifique o programa anterior para imprimir primeiro a maior lista de anagramas, seguida pela segunda mais longa e assim por diante.

  3. No Scrabble, um “bingo” é quando você joga usa as sete peças do seu suporte, juntamente com uma letra no tabuleiro, para formar uma palavra de oito letras. Qual é a coleção de oito letras que forma o maior número possível de bingos?

Exercício 12-4

Duas palavras formam um “par de metátese” se você puder transformar uma na outra trocando duas letras, como no caso de “converse“ e “conserve”. Escreva um programa que encontre todos os pares de metáteses no dicionário.

Dica

Não teste todos os pares de palavras e nem todas as trocas possíveis.

Crédito: Este exercício é inspirado em um exemplo em http://puzzlers.org.

Exercício 12-5

Aqui está outro desafio do programa Car Talk (https://www.cartalk.com/puzzler/browse):

Qual é a palavra em Inglês mais longa, que permanece uma palavra válida em Inglês à medida que você remove as letras uma de cada vez?

Agora, as letras podem ser removidas de qualquer posição e você não pode rearranjar nenhuma das letras. Toda vez que você retirar uma letra, você termina com outra palavra em Inglês. Se você fizer isso, acabará terminando com uma letra e essa também será uma palavra em Inglês que pode ser encontrada no dicionário. Quero saber qual é a palavra mais longa e quantas letras ela tem?

Vou dar um exemplo modesto: Sprite. OK? Você começa com sprite, tira a letra r no meio da palavra e ficamos com a palavra spite, depois tiramos a letra e no final e ficamos com spit, tiramos a letra s e ficamos com pit, it e I.

Escreva um programa para encontrar todas as palavras em Inglês que podem ser reduzidas desta forma, e depois encontre a mais longa.

Dica

Este exercício é um pouco mais desafiador do que a maioria, então aqui vai algumas sugestões:

  1. Você pode escrever uma função que recebe uma palavra e obtenha uma lista de todas as palavras que podem ser formadas removendo uma letra. Esta lista contém os “filhos” da palavra.

  2. Recursivamente, uma palavra é redutível se algum de seus filhos for redutível. Como caso base, pode-se considerar a string vazia redutível.

  3. A lista de palavras que disponibilizei ( palavras.txt do cap. 9) não contém palavras com uma única letra. Então, você pode querer colocar “I”, “a” e a string vazia.

  4. Para melhorar o desempenho do seu programa, convém guardar as palavras que são conhecidas por serem redutíveis.

13. Estudo de Caso: Seleção de Estrutura de Dados

A essa altura você deve ter aprendido sobre as estruturas de dados essenciais do Julia e também deve ter visto alguns dos algoritmos que usam elas.

Neste capítulo apresentaremos um estudo de caso com os exercícios que permitirão pensar na escolha de estruturas de dados e praticar o uso delas.

Análise de Frequência de Palavras

Como sempre, você deve pelo menos tentar fazer os exercícios antes de ver as soluções.

Exercício 13-1

Escreva um programa que leia um arquivo, separe cada linha em palavras, retire o espaço em branco e a pontuação das palavras e coloque-as em letras minúsculas.

Dica

A função isletter verifica se o caractere é do alfabeto.

Exercício 13-2

Va até o Projeto Gutenberg (https://guttenberg.org) e baixe seu livro favorito de domínio público em texto simples, isto é, texto sem formatação.

Modifique o seu programa do exercício anterior para ler o livro que você baixou, pule a informação do cabeçalho no início do arquivo e trabalhe com o resto das palavras como no exercício anterior.

Depois, modifique o programa para contar o número total de palavras do livro e o número de vezes que cada palavra é usada.

Imprima o número das palavras diferentes usadas no livro. Compare outros livros de autores diferentes, escritos em épocas diferentes. Qual autor usa um vocabulário mais extenso?

Exercício 13-3

Modifique o programa do exercício anterior para imprimir as 20 palavras mais frequentes utilizadas no livro.

Exercício 13-4

Modifique o programa anterior para ler uma lista de palavras e depois imprima todas as palavras do livro que não estão nessa lista. Quantas delas são erros de digitação? Quantas delas são palavras comuns que deveriam estar na lista de palavras e quantas delas são realmente obscuras?

Números Aleatórios

Dadas as mesmas entradas, a maioria dos programas de computadores sempre geram a mesma saída e por isso eles são ditos determinísticos. O determinismo é geralmente uma coisa boa pois esperamos que o mesmo cálculo produza o mesmo resultado. Mas para algumas aplicações, queremos que o computador seja imprevisível. Os jogos são um exemplo óbvio, porém há mais exemplos.

Desenvolver um programa puramente não determinístico acaba sendo difícil, mas há maneiras de fazer pelo menos com que ele pareça não determinístico. Uma delas é usar algoritmos que geram números pseudoaleatórios. Os números pseudoaleatórios não são aleatórios de verdade porque eles são gerados por um cálculo determinístico, mas só olhando, é praticamente impossível distingui-los da aleatoriedade.

A função rand retorna um ponto flutuante aleatório entre 0.0 e 1.0 (incluindo 0.0 mas não 1.0). Toda vez que se usa o comando rand, você obtém o próximo número de uma longa série. Para ver uma amostra disso, execute este laço:

for i in 1:10
    x = rand()
    println(x)
end

A função rand pode receber um iterador ou uma lista como argumentos e retorna um elemento aleatório:

for i in 1:10
    x = rand(1:6)
    print(x, " ")
end
Exercício 13-5

Escreva uma função chamada escolhido_do_histograma que recebe um histograma definido em Dicionários como uma Coleção de Contadores e retorna um valor aleatório do histograma, escolhido conforme a probabilidade proporcional à frequência. Por exemplo, para este histograma:

julia> t = ['a', 'a', 'b'];

julia> histograma(t)
Dict{Any,Any} with 2 entries:
  'a' => 2
  'b' => 1

a sua função deve retornar 'a' com a probabilidade \(\frac{2}{3}\) e 'b' com a probabilidade \(\frac{1}{3}\).

Histograma de Palavra

Você deve tentar fazer os exercícios anteriores antes de continuar. Você também precisará de https://github.com/JuliaIntro/JuliaIntroBR.jl/blob/master/data/emma.txt.

Logo abaixo, temos um programa que lê um arquivo e cria um histograma das palavras no arquivo:

function processa_arquivo(nome_do_arquivo)
    hist = Dict()
    for linha in eachline(nome_do_arquivo)
        processa_linha(linha, hist)
    end
    hist
end;

function processa_linha(linha, hist)
    linha = replace(linha, '-' => ' ')
    for palavra in split(linha)
        palavra = string(filter(isletter, [palavra...])...)
        palavra = lowercase(palavra)
        hist[palavra] = get!(hist, palavra, 0) + 1
    end
end;
hist = processa_arquivo("emma.txt");

Esse programa lê emma.txt, que contém o texto de Emma escrito por Jane Austen.

A função processa_arquivo percorre as linhas do arquivo passando-as uma vez de cada para processa_linha. O histograma hist é utilizado como um acumulador.

A função processa_linha usa a função replace para substituir os hífens e espaços antes de usar split para separar a linha em uma lista de strings. Ela percorre a lista de palavras e usa filter, isletter e lowercase para remover as pontuações e converter em letras minúsculas. (Apesar de dizer que as strings são “convertidas”, lembre-se de que as strings são imutáveis e portanto, uma função como lowercase retorna novas strings.)

Finalmente, processa_linha atualiza o histograma criando um novo item ou incrementando um já existente.

Para contar o número total de palavras no arquivo, podemos adicionar as frequências no histograma:

function total_de_palavras(hist)
    sum(values(hist))
end

O número de palavras diferentes é apenas o número de itens no dicionário:

function palavras_diferentes(hist)
    length(hist)
end

A seguir, um código que imprime os resultados:

julia> println("Número total de palavras: ", total_de_palavras(hist))
Número total de palavras: 162742

julia> println("Número de palavras diferentes: ", palavras_diferentes(hist))
Número de palavras diferentes: 7380

Palavras Mais Frequentes

Para encontrar as palavras mais frequentes, podemos fazer uma lista de tuplas, onde cada tupla contém uma palavra e a sua respectiva frequência e fazemos a ordenação. A função seguinte recebe um histograma e retorna uma lista de tuplas que contém a frequência de palavras.

function mais_comum(hist)
    t = []
    for (chave, valor) in hist
        push!(t, (valor, chave))
    end
    reverse(sort(t))
end

Em cada tupla, a frequência aparece primeiro, então o resultado da lista é ordenada pela frequência. Aqui está um laço que imprime as 10 palavras mais frequentes:

t = mais_comum(hist)
println("As palavras mais frequentes são: ")
for (freq, palavra) in t[1:10]
    println(palavra, "\t", freq)
end

Usamos o caractere tab ('\t') como um “separador”, ao invés de um espaço, fazendo com que a segunda coluna fique alinhada. Abaixo, os resultados de Emma:

As palavras mais frequentes são:
to	5295
the	5266
and	4931
of	4339
i	3191
a	3155
it	2546
her	2483
was	2400
she	2364
Dica

Esse código pode ser simplificado usando a palavra-chave rev como argumento da função sort. Você pode ler mais sobre isto em https://docs.julialang.org/en/v1/base/sort/#Base.sort.

Parâmetros Opcionais

Temos visto funções embutidas que recebem argumentos opcionais. É possível escrever as funções com argumentos opcionais também. Por exemplo, eis uma função que imprime as palavras mais frequentes em um histograma:

function imprime_mais_frequentes(hist, num=10)
    t = mais_comum(hist)
    println("As palavras mais frequentes são: ")
    for (freq, palavra) in t[1:num]
        println(palavra, "\t", freq)
    end
end

O primeiro parâmetro é obrigatório, enquanto que o segundo é opcional. O valor padrão de num é 10.

Se você fornecer apenas um argumento:

imprime_mais_frequentes(hist)

o argumento num recebe o valor padrão. Se você fornecer dois argumentos:

imprime_mais_frequentes(hist, 20)

o argumento num fica com o valor passado no argumento. Em outras palavras, o valor opcional sobrepõe o valor padrão.

Se uma função possui tanto os argumentos obrigatórios e os opcionais, todos os parâmetros obrigatórios deverão ficar entre os primeiros, seguido dos opcionais.

Subtração de Dicionário

Encontrar as palavras do livro que não estão na lista de palavras em palavras.txt é um problema que você pode reconhecer como subtração de conjuntos, isto é, queremos encontrar todas as palavras de um conjunto (as palavras do livro) que não estão no outro (as palavras da lista).

A função subtrair recebe os dicionários d1 e d2 e retorna um novo dicionário que contém todas as chaves de d1 que não estão em d2. Como realmente não nos importamos com os valores, definimos todos como nothing.

function subtrair(d1, d2)
    res = Dict()
    for chave in keys(d1)
        if chave ∉ keys(d2)
            res[chave] = nothing
        end
    end
    res
end

Para encontrar as palavras do livro que não estão em palavras.txt, podemos usar processa_arquivo para construir um histograma para palavras.txt, e depois subtrair:

palavras = processa_arquivo("palavras.txt")
diferença = subtrair(hist, palavras)

println("Palavras do livro que não estão na lista de palavras: ")
for palavra in keys(diferença)
    print(palavra, " ")
end

Eis alguns resultados de Emma:

Palavras do livro que não estão na lista de palavras:
outree quicksighted outwardly adelaide rencontre jeffereys unreserved dixons betweens ...

Algumas dessas palavras são nomes e preposições. Outros, como “rencontre” não são mais usados. Mas algumas são as palavras comuns que realmente devem estar na lista!

Exercício 13-6

O Julia fornece uma estrutura de dados chamado Set que fornece várias operações usuais de conjuntos. Você pode ler mais sobre elas em Coleções e Estruturas de Dados, ou ler a documentação em https://docs.julialang.org/en/v1/base/collections/#Set-Like-Collections-1.

Escreva um programa que usa a subtração de conjuntos para encontrar as palavras do livro que não estão na lista de palavras.

Palavras Aleatórias

Para escolher uma palavra aleatória do histograma, o algoritmo mais simples é construir uma lista com múltiplas cópias de cada palavra, de acordo com a frequêcia observada e depois escolher da lista:

function palavra_aleatória(h)
    t = []
    for (palavra, freq) in h
        for i in 1:freq
            push!(t, palavra)
        end
    end
    rand(t)
end

Esse algoritmo funciona, mas não é muito eficiente; toda vez que você escolhe uma palavra aleatória, ele reconstrói a lista, o que é tão grande quanto o livro original. Uma melhoria óbvia é construir uma lista uma vez e então realizar múltiplas seleções, mas a lista continua grande.

Uma alternativa é:

  1. Usar keys para obter uma lista das palavras do livro.

  2. Construir uma lista que contenha uma soma acumulativa da frequência da palavra (veja Exercício 10-2). O último item nesta lista é o número total de palavras no livro, \(n\).

  3. Escolher um número aleatório de 1 até \(n\). Usar uma busca por bissecção (veja Exercício 10-10) para encontrar o índice no qual o número aleatório deverá ser inserido na soma acumulativa.

  4. Usar o índice para encontrar a palavra correspondente na lista de palavras.

Exercício 13-7

Escreva um programa que usa esse algoritmo para escolher uma palavra aleatória do livro.

Análise de Markov

Se você escolher as palavras do livro aleatoriamente, pode-se obter um senso de vocabulário, mas você provavelmente não obterá uma sentença:

this the small regard harriet which knightley's it most things

Uma série de palavras aleatórias raramente faz sentido pois não há relação com as palavras sucessivas. Por exemplo, numa sentença real você esperaria um artigo como “the” ser seguido por um adjetivo ou um substantivo, e provávelmente não um verbo ou advérbio.

Um jeito de medir essa relação é através da análise de Markov, que caracteriza, para uma sequência de palavras dadas, a probabilidade das palavras que possam vir a seguir. Por exemplo, a música Amor Pra Recomeçar (do Frejat) tem o seguinte trecho:

Eu te desejo não parar tão cedo
Pois toda idade tem prazer e medo
E com os que erram feio e bastante
Que você consiga ser tolerante

Quando você ficar triste
Que seja por um dia, e não o ano inteiro
E que você descubra que rir é bom,
mas que rir de tudo é desespero

Desejo que você tenha a quem amar
E quando estiver bem cansado
Ainda, exista amor pra recomeçar
Pra recomeçar

Eu te desejo muitos amigos
Mas que em um você possa confiar
E que tenha até inimigos
Pra você não deixar de duvidar
Quando você ficar triste

No texto, o trecho “eu te” é sempre seguido da palavra “desejo”, mas o trecho “te desejo” pode ser seguido de “não” ou “muitos”.

O resultado da análise de Markov é um mapeamento de cada prefixo (como “eu te” e “te desejo”) a todos os possíveis sufixos (como “não” ou “muitos”).

Dado esse mapeamento, você pode gerar um texto aleatório começando com qualquer prefixo e escolhendo aleatoriamente dentre os possíveis sufixos. Em seguida, você pode combinar o final do prefixo e o novo sufixo para formar o próximo prefixo, e repetir.

Por exemplo, se você começar com o prefixo “eu te”, então a próxima palavra deverá ser “desejo”, pois é o prefixo que aparece apenas uma vez no texto. O próximo prefixo é “te desejo”, então o próximo sufixo poderá ser “não” ou “muitos”.

Nesse exemplo o tamanho do prefixo é sempre dois, mas você pode fazer uma análise de Markov com qualquer tamanho de prefixo.

Exercício 13-8

Análise de Markov:

  1. Escreva um programa que leia um texto de um arquivo e realize a análise de Markov. O resultado deverá ser um dicionário que mapeia os prefixos a uma coleção dos possíveis sufixos. A coleção poderá ser uma lista, tupla ou um dicionário; cabe a você fazer uma escolha apropriada. Você pode testar seu programa com o comprimento do prefixo dois, mas deve escrever o programa de uma maneira que facilite a tentativa de outros comprimentos.

  2. Adicione uma função ao programa anterior para gerar textos aleatórios baseados na análise de Markov. Aqui vai um exemplo de Emma com prefixo de tamanho 2:

    “He was very clever, be it sweetness or be angry, ashamed or only amused, at such a stroke. She had never thought of Hannah till you were never meant for me?" "I cannot make speeches, Emma:" he soon cut it all himself.”

    Nesse exemplo, eu deixei a pontuação anexada às palavras. O resultado é quase sintaticamente correto, mas não exatamente. Semanticamente, quase faz sentido, mas não completamente.

    O que aconteceria se você aumentasse o tamanho dos prefixos? Será que o texto aleatório faria mais sentido?

  3. Depois que o programa estiver funcionando, convém tentar uma combinação: se você combinar textos de dois ou mais livros, o texto aleatório gerado irá mesclar o vocabulário e as frases das fontes de maneiras interessantes.

Crédito: Esse estudo de caso é baseado em um exemplo de Kernighan e Pike, The Practice of Programming, Addison-Wesley, 1999.

Dica

Você deveria tentar fazer esse execício antes de continuar.

Estruturas de Dados

Usar a análise de Markov para gerar textos aleatórios é divertido, mas há também um propósito para este exercício: a seleção da estrutura de dados. Na sua solução para os exercícios anteriores, você teve que escolher:

  • Como representar os prefixos.

  • Como representar a coleção dos possíveis sufixos.

  • Como representar um mapeamento de cada prefixo à coleção dos possíveis sufixos.

A última é fácil: Um dicionário é a escolha óbvia para um mapeamento de chaves aos valores correspondentes.

Para os prefixos, as opções mais óbvias são as strings, listas de strings ou tuplas de strings.

Para os sufixos, uma opção é uma lista; outra é um histograma (dicionário).

Como você deve escolher? O primeiro passo é pensar nas operações que você precisará implementar para cada estrutura de dados. Para os prefixos, precisamos remover palavras do começo e adicionar ao final. Por exemplo, se o prefixo atual é “eu te” e a próxima palavra é “desejo”, você precisa formar o próximo prefixo, “te desejo”.

Sua primeira escolha pode ser uma lista, pois é fácil adicionar e remover os elementos.

Para a coleta dos sufixos, as operações que precisamos executar incluem a adição de um novo sufixo (ou aumento da frequência de um existente) e a seleção de um sufixo aleatório.

Adicionar um novo sufixo é igualmente fácil para a implementação da lista ou do histograma. Escolher um elemento aleatório de uma lista é fácil enquanto que escolher de um histograma é mais difícil de ser feito eficientemente (veja Exercício 13-7).

Até agora, conversamos principalmente sobre a facilidade de implementação, mas há outros fatores a serem considerados na escolha das estruturas de dados. Um deles é o tempo de execução. Às vezes, existe uma razão teórica para esperar que uma estrutura de dados seja mais rápida que outra; por exemplo, mencionamos que o operador in é mais rápido para os dicionários do que para as listas, pelo menos quando o número de elementos é grande.

Mas muitas vezes você não sabe antecipadamente qual implementação será mais rápida. Uma opção é implementar os dois e ver qual é o melhor. Essa abordagem é chamado de benchmarking. Uma alternativa prática é escolher a estrutura de dados mais fácil de implementar e verificar se é rápida o suficiente para a aplicação pretendida. Se sim, não há necessidade de continuar. Caso contrário, existem ferramentas, como o módulo Profile, que podem identificar os locais em um programa que mais demoram.

O outro fator a considerar é o espaço de armazenamento. Por exemplo, o uso de um histograma para a coleção de sufixos pode exigir menos espaço, pois você só precisa armazenar cada palavra uma vez, não importa quantas vezes apareça no texto. Em alguns casos, economizar espaço também pode fazer com que seu programa seja executado mais rapidamente e, em um caso extremo, seu programa poderá não executar se você ficar sem memória. Porém, para muitos aplicativos, o espaço é uma consideração secundária após o tempo de execução.

Uma indagação final: nesta discussão, sugerimos que devemos usar uma estrutura de dados para a análise e geração. Mas como essas fases são separadas, também seria possível usar uma estrutura para a análise e depois converter em outra estrutura para a geração. Isso seria uma vitória se o tempo economizado durante a geração excedesse o tempo gasto na conversão.

Dica

O pacote DataStructures do Julia (consulte https://github.com/JuliaCollections/DataStructures.jl) implementa uma variedade de estruturas de dados.

Depuração

Quando você está depurando um programa, e especialmente se você está trabalhando em um erro difícil, existem cinco atividades para se tentar:

Leitura

Examine o seu código, leia para si mesmo e verifique se está condizendo com o que você quis dizer.

Execução

Experimente fazer as alterações e executar versões diferentes. Geralmente se você exibe a coisa certa no lugar certo no programa, o problema se torna óbvio, apesar de às vezes você ter que construir andaimes.

Ruminação

Tire algum tempo para pensar! Qual o tipo de erro é: sintaxe, tempo de execução ou semântica? Quais informações você pode obter das mensagens de erro ou da saída do programa? Que tipo de erro pode causar o problema que você está tendo? O que você mudou por último, antes que o problema aparecesse?

Conversa com o Pato de Borracha (rubberducking)

Se você explicar o problema para outra pessoa, às vezes encontrará a resposta antes de terminar de fazer a pergunta. Muitas vezes você não precisa da outra pessoa, poderia apenas conversar com um pato de borracha. E essa é a origem da familiar estratégia chamada depuração com o pato de borracha. Não estamos inventando isso, veja https://pt.wikipedia.org/wiki/Debug_com_Pato_de_Borracha.

Recuo

Em um determinado ponto, a melhor coisa a fazer é voltar atrás e desfazer as alterações recentes, até voltar a ter um programa que funcione e que você entenda. Então você pode começar a reconstruir.

Programadores iniciantes às vezes ficam presos em uma dessas atividades e esquecem das outras. Cada atividade tem a sua própria maneira de falhar.

Por exemplo, a leitura do seu código pode ajudar se o problema é um erro tipográfico, mas não se o problema for conceitual. Se você não entende o que o seu programa faz, pode lê-lo cem vezes e nunca verá o erro, porque o erro está na sua cabeça.

Realizar experimentos pode ajudar, especialmente se você executar testes pequenos e simples. No entanto, se você executar experimentos sem pensar ou ler seu código, pode cair em um padrão que eu chamo de “programação aleatória”, que é o processo de fazer alterações aleatórias até que o programa faça a coisa certa. Obviamente, a programação aleatória pode levar muito tempo.

Você precisa ter um tempo para pensar. Depuração é como um experimento científico. Deve haver pelo menos uma hipótese sobre qual é o problema. Se houver duas ou mais possibilidades, tente pensar em um teste que eliminaria uma delas.

Mas até mesmo as melhores técnicas de depuração falham se houver erros demais, ou se o código que você está tentando corrigir for muito grande e complicado. Às vezes, a melhor opção é voltar atrás, simplificando o programa até chegar a algo que funcione e que você entenda.

Programadores iniciantes muitas vezes relutam em voltar atrás porque não conseguem eliminar uma linha de código (mesmo se estiver errada). Se isso faz você se sentir melhor, copie seu programa para um outro arquivo antes de começar a desmontá-lo. Então você pode copiar as partes de volta, uma a uma.

Encontrar um erro difícil exige a leitura, execução, ruminação, e, às vezes, o recuo. Se você empacar em alguma dessas atividades, tente as outras.

Glossário

determinístico

Referente a um programa que faz a mesma coisa toda vez que é executado quando se é fornecida a mesma entrada.

pseudoaleatório

Referente a uma sequência de números que parecem ser aleatórios, mas é gerada por um programa determinístico.

valor padrão

O valor dado a um parâmetro opcional se nenhum argumento é fornecido.

sobreposição

A sobreposição de um valor padrão por um argumento.

benchmarking

O processo de seleção das estruturas de dados ao implementar as alternativas e testá-las em uma amostra com as possíveis entradas.

depuração com pato de borracha

Depuração ao explicar seu problema a um objeto inanimado como um pato de borracha. Articular o problema pode te ajudar a resolvê-lo, mesmo que o pato de borracha não conheça o Julia.

Exercícios

Exercício 13-9

O “ranque” de uma palavra é a sua posição em uma lista ordenada pela frequência: a palavra mais comum tem ranque 1, a segunda mais comum tem ranque 2, etc.

A lei de Zipf descreve a relação entre os ranque e as frequências das palavras nas linguagens naturais (https://pt.wikipedia.org/wiki/Lei_de_Zipf). Especificamente, ela prediz que a frequência \(f\) da palavra com o ranque \(r\) é dada por:

\[\begin{equation} {f = c r^{-s}} \end{equation}\]

onde \(s\) e \(c\) são os parâmetros que dependem da linguagem e do texto. Se você pegar o logaritmo em ambos os lados desta equação, obtém-se:

\[\begin{equation} {\log f = \log c - s \log r} \end{equation}\]

Se você plotar \(\log f\) por \(\log r\), obterá uma linha reta com a inclinação \(-s\) e o intercepto \(\log c\).

Escreva um programa que leia um texto em um arquivo, conta as frequências das palavras e exibe uma linha para cada palavra, em ordem decrescente da frequência, com \(\log f\) e \(\log r\).

Instale uma biblioteca para plotar os gráficos:

(v1.0) pkg> add Plots

Seu uso é muito simples:

using Plots
x = 1:10
y = x.^2
plot(x, y)

Use a biblioteca Plots para plotar os resultados e verificar se eles formam ou não uma linha reta.

14. Arquivos

Este capítulo introduz a ideia de programas “persistentes” que mantêm dados permanentemente armazenados e mostra como usar tipos diferentes de armazenamentos permanentes, como arquivos e banco de dados.

Persistência

A maioria dos programas que nós vimos até agora são transitórios no sentido de que eles executam por um tempo e produzem alguma saída, mas ao finalizarem, seus dados desaparecem. Se você executar o programa novamente, ele inicia com um estado limpo.

Outros programas são persistentes: eles executam por um longo tempo (ou toda hora); eles mantém pelo menos um pouco dos dados em um armazenamento permanente (um disco rígido, por exemplo); e se eles forem encerrados e reiniciados, eles retornam aonde pararam.

Exemplos de programas persistentes são sistemas operacionais, que são executados toda vez que um computador é ligado e servidores web, que são executados toda hora, aguardando solicitações vindas da rede.

Uma das maneiras mais simples que os programas usam para manter seus dados é lendo e escrevendo arquivos de texto. Nós já vimos programas que leem arquivos de texto; neste capítulo nós veremos programas que os escrevem.

Uma alternativa é armazenar o estado do programa em um banco de dados. Neste capítulo, iremos apresentar como usar um banco de dados simples.

Lendo e Escrevendo

Um arquivo de texto é uma sequência de caracteres armazenado em um meio permanente como um disco rígido ou memória flash. Nós vimos como abrir e ler um arquivo em Lendo Listas de Palavras.

Para escrever em um arquivo, você tem que abri-lo com modo "w" como segundo parâmetro:

julia> fout = open("saida.txt", "w")
IOStream(<file saida.txt>)

Se o arquivo já existe, abri-lo em modo escrita limpa os dados antigos e começa de novo, então tome cuidado! Se o arquivo não existe, um novo é criado. open retorna um objeto arquivo e a função write coloca dados dentro do arquivo.

julia> linha1 = "No meio do caminho tinha uma pedra,\n";

julia> write(fout, linha1)
36

O valor de retorno é o número de caracteres que foram escritos. O arquivo objeto acompanha aonde ele está, então se você chamar write novamente, ela adiciona novos dados ao final do arquivo.

julia> linha2 = "tinha uma pedra no meio do caminho.\n";

julia> write(fout, linha2)
36

Quando você terminar de escrever no arquivo, ele deve ser fechado.

julia> close(fout)

Se você não fechar o arquivo, ele é fechado quando o programa encerra.

Formatação

O argumento da função write precisa ser uma string, então se quisermos colocar outros valores no arquivo, temos que convertê-los para string. A maneira mais fácil de fazer isso é usando string ou interpolação de strings:

julia> fout = open("saida.txt", "w")
IOStream(<file saida.txt>)
julia> write(fout, string(150))
3

Uma alternativa é usar a familia de funções print(ln).

julia> camelos = 42
42
julia> println(fout, "Eu vi $camelos camelos.")
Dica

Uma alternativa mais poderosa é a macro @printf que imprime usando uma string de especificação de formato de estilo C, você pode ler mais a respeito em https://docs.julialang.org/en/v1/stdlib/Printf/

Nomes de Arquivos e Caminhos

Arquivos são organizados em diretórios (também chamados de “pastas”). Todo programa em execução possui um “diretório atual”, que é o diretório padrão para a maioria das operações. Por exemplo, quando você abre um arquivo para leitura, o Julia a procura no diretório atual.

A função pwd retorna o nome do diretório atual:

julia> cwd = pwd()
"/home/ben"

cwd significa “current working directory” (diretório de trabalho atual). O resultado nesse exemplo é home/ben, que é o diretório home do usuário chamado ben.

Uma string como "/home/ben" que identifica um arquivo ou diretório é chamada de caminho.

Um nome de arquivo simples, como memo.txt também é considerado um caminho, mas é um caminho relativo porque se refere ao diretório atual. Se o diretório atual é /home/ben, o nome do arquivo memo.txt se referiria à /home/ben/memo.txt.

Um caminho que começa com / não depende do diretório atual; ele é chamado de caminho absoluto. Para encontrar um caminho absoluto para um arquivo, você pode usar abspath:

julia> abspath("memo.txt")
"/home/ben/memo.txt"

O Julia fornece outras funções para trabalhar com nomes de arquivos e caminhos. Por exemplo, ispath verifica se um arquivo ou diretório existe:

julia> ispath("memo.txt")
true

Se ele existe, isdir verifica se é um diretório:

julia> isdir("memo.txt")
false
julia> isdir("/home/ben")
true

De forma similar, isfile verifica se é um arquivo.

readdir retorna uma lista de arquivos (e outros diretórios) no diretório dado:

julia> readdir(cwd)
3-element Array{String,1}:
 "memo.txt"
 "música"
 "fotos"

Para demonstrar estas funções, o exemplo a seguir “caminha” por um diretório, exibe os nomes de todos os arquivos e chama a si mesmo recursivamente em todos os diretórios.

function caminha(nomedir)
    for nome in readdir(nomedir)
        path = joinpath(nomedir, nome)
        if isfile(path)
            println(path)
        else
            caminha(path)
        end
    end
end

joinpath recebe um diretório e um nome de arquivo e junta-os em um caminho completo.

Dica

O Julia fornece uma função chamada walkdir (consulte https://docs.julialang.org/en/v1/base/file/#Base.Filesystem.walkdir) que é similar a esta, porém, é mais versátil. Como um exercício, leia a documentação e a use para imprimir os nomes dos arquivos em um dado diretório e seus subdiretórios.

Capturando Exceções

Muitas coisas podem dar errado quando você tenta ler e escrever arquivos. Se você tentar abrir um arquivo que não existe, você recebe um SystemError:

julia> fin = open("arquivo_ruim")
ERROR: SystemError: opening file "arquivo_ruim": No such file or directory

Se você não tem permissão para acessar o arquivo:

julia> fout = open("/etc/passwd", "w")
ERROR: SystemError: opening file "/etc/passwd": Permission denied

Para evitar estes erros, você pode usar funções como ispath e isfile, porém, tomaria muito tempo e código para verificar todas as possibilidades.

É mais fácil tentar de uma vez—e lidar com os problemas se eles ocorrerem—que é exatamente o que a declaração try faz. A sintaxe é similar a uma declaração if:

try
    fin = open("arquivo_ruim.txt")
catch exc
    println("Algo deu errado: $exc")
end

O Julia inicia executando a cláusula try. Se tudo der certo, ele pula a cláusula catch e segue adiante. Se ocorrer alguma exceção, ele pula fora da cláusula try e executa a cláusula catch.

Lidar com uma exceção com uma declaração try é chamado de capturar uma exceção. Neste exemplo, a cláusula de exceção imprime uma mensagem de erro que não é muito útil. Em geral, capturar uma exceção nos da uma chance de consertar o problema, ou tentar novamente ou pelo menos encerrar o programa graciosamente.

Em código que realiza mudanças de estado ou usa recursos como arquivos, geralmente há um trabalho de limpeza (como fechar arquivos) que precisa ser feito quando o código é encerrado. Exceções potencialmente complicam esta tarefa, já que elas podem causar a saída de um bloco de código antes dele atingir seu fim normal. A palavra-chave finally fornece uma maneira de executar código quando um dado bloco de código sai, independente de como ele saiu:

f = open("output.txt")
try
    line = readline(f)
    println(line)
finally
    close(f)
end

A função close sempre será executada.

[[banco de dados]] === Banco de Dados

Um banco de dados é um arquivo que é organizado para guardar dados. Muitos bancos de dados são organizados como um dicionário no sentido de que eles mapeiam chave para valores. A maior diferença entre um banco de dados e um dicionário é de que o banco de dados está em disco (ou em armazenamento permanente), então ele persiste após o programa encerrar.

O JuliaIntroBR fornece uma interface para GDBM (GNU dbm) para criar e atualizar arquivos de bancos de dados. Como um exemplo, irei criar um banco de dados que contém legendas para arquivos de imagens.

Abrir um banco de dados é similar a abrir outros arquivos:

julia> using JuliaIntroBR

julia> db = DBM("legendas", "c")
DBM(<legendas>)

O modo "c" significa que o banco de dados deve ser criado se ele ainda não existe. O resultado é um objeto banco de dados que pode ser usado (para a maioria das operações) como um dicionário.

Quando você cria um novo item, GDBM atualiza o arquivo banco de dados:

julia> db["cleese.png"] = "Foto de John Cleese."
"Foto de John Cleese."

Quando você acessa um de seus itens, GDBM lê o arquivo:

julia> db["cleese.png"]
"Foto de John Cleese."

Se você faz outra atribuição para uma chave já existente, GDBM substitui o valor antigo:

julia> db["cleese.png"] = "Foto de John Cleese fazendo uma caminhada engraçada."
"Foto de John Cleese fazendo uma caminhada engraçada."
julia> db["cleese.png"]
"Foto de John Cleese fazendo uma caminhada engraçada."

Algumas funções que tem um dicionário como argumento, como keys e values, não funcionam com objetos banco de dados. Mas iteração com o laço for funciona:

for (chave, valor) in db
    println(chave, ": ", valor)
end

Como outros arquivos, você deve fechar o banco de dados quando acabar:

julia> close(db)

Serialização

A limitação do GDBM é de que as chaves e valores precisam ser strings ou listas de bytes. Se você tentar usar qualquer outro tipo, você recebe um erro.

As funções serialize e deserialize podem ajudar. Elas traduzem quase todo tipo de objeto em uma lista de bytes (um iobuffer) adequado para armazenamento em um banco de dados e em seguida traduz a lista de bytes em objetos:

julia> using Serialization

julia> io = IOBuffer();

julia> t = [1, 2, 3];

julia> serialize(io, t)
24
julia> print(take!(io))
UInt8[0x37, 0x4a, 0x4c, 0x09, 0x04, 0x00, 0x00, 0x00, 0x15, 0x00, 0x08, 0xe2, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]

O formato não é óbvio para humanos; ele tem o propósito de ser fácil para o Julia interpretá-lo. deserialize reconstitui o objeto:

julia> io = IOBuffer();

julia> t1 = [1, 2, 3];

julia> serialize(io, t1)
24
julia> s = take!(io);

julia> t2 = deserialize(IOBuffer(s));

julia> print(t2)
[1, 2, 3]

serialize e deserialize escrevem para e leem de um objeto iobuffer que representa um fluxo I/O em memória. A função take! busca os conteúdos do iobuffer como uma lista de bytes e reinicia o iobuffer para seu estado inicial.

Apesar do novo objeto ter o mesmo valor do antigo, ele não é (em geral) o mesmo objeto:

julia> t1 == t2
true
julia> t1 ≡ t2
false

Em outras palavras, serialização e em seguida desserialização tem o mesmo efeito de copiar o objeto.

Você pode usar isso para guardar coisas diferentes de strings em um banco de dados.

Dica

Na verdade, o armazenamento de coisas diferentes de string em um banco de dados é tão comum que ele foi encapsulado em um pacote chamado JLD2 (ver https://github.com/JuliaIO/JLD2.jl).

Objetos de Comando

A maioria dos sistemas operacionais fornecem uma interface de linha de comando, também conhecida como uma shell. Shells geralmente fornecem comandos para navegar o sistema de arquivos e iniciar aplicações. Por exemplo, no Unix você pode mudar de diretório com o comando cd, exibir o conteúdo do diretório com ls e iniciar o web browser digitando (por exemplo) firefox.

Qualquer programa que você inicia do shell também pode ser iniciado do Julia usando um objeto de comando:

julia> cmd = `echo olá`
`echo olá`

Acentos graves são usados para delimitar o comando.

A função run executa o comando:

julia> run(cmd);
olá

O olá é o resultado do comando echo, mandado para STDOUT. A função run retorna um objeto de processo, e gera um ErrorException se o comando externo falha ao executar com sucesso.

Se você quer ler a saída do comando externo, read pode ser usado como alternativa:

julia> a = read(cmd, String)
"olá\n"

Por exemplo, a maioria dos sistemas Unix fornecem um comando chamado md5sum ou md5 que lê o conteúdo de um arquivo e calcula uma “soma de verificação” para checar a integridade dos dados. Você pode ler mais sobre MD5 em https://pt.wikipedia.org/wiki/MD5. Este comando fornece uma maneira eficiente de verificar se dois arquivos possuem o mesmo conteúdo. A probabilidade de que conteúdos diferentes produzem a mesma soma de verificação é muito pequena (isto é, improvável de acontecer antes que o universo entre em colapso).

Você pode usar um objeto comando para executar md5 do Julia e gerar o resultado:

julia> nomedoarquivo = "saida.txt"
"saida.txt"
julia> cmd = `md5 $nomedoarquivo`
`md5 saida.txt`
julia> res = read(cmd, String)
ERROR: IOError: could not spawn `md5 saida.txt`: no such file or directory (ENOENT)

Módulos

Suponha que você tenha um arquivo chamado "wc.jl" com o seguinte código:

function contalinha(nomedoarquivo)
    contador = 0
    for linha in eachline(nomedoarquivo)
        contador += 1
    end
    contador
end

print(contalinha("wc.jl"))

Se você executar esse programa, ele lê a si mesmo e imprime o número de linhas de um arquivo, que é 9. Você também pode incluir ele no REPL assim:

julia> include("wc.jl")
9

O Julia introduz módulos para criar uma área de trabalho de variáveis separada, isto é, novos escopos globais.

Um módulo inicia com a palavra-chave module e termina com end. Conflitos de nomes são evitados entre suas próprias definições de alto nível e aquelas encontradas em código de outra pessoa. import permite o controle de quais nomes de outros módulos são visíveis e export específica quais nomes são públicos, isto é, podem ser usados fora do módulo sem a necessidade de serem prefixados com o nome do módulo.

module ContaLinha
    export contalinha

    function contalinha(nomedoarquivo)
        contador = 0
        for linha in eachline(nomedoarquivo)
            contador += 1
        end
        contador
    end
end

O objeto de tipo módulo ContaLinha fornece contalinha:

julia> using ContaLinha

julia> contalinha("wc.jl")
11
Exercício 14-1

Digite este exemplo em um arquivo chamado wc.jl, inclua-o no REPL e insira using ContaLinha.

Atenção

Se você importar um módulo que já foi importado, o Julia não faz nada. Ele não relê o arquivo, mesmo que ele tenha sido alterado.

Se você quer recarregar o módulo, você tem que reiniciar o REPL. O pacote Revise existe para que você possa manter suas sessões rodando por mais tempo (ver https://github.com/timholy/Revise.jl).

Depuração

Quando você está lendo ou escrevendo arquivos, você pode encontrar problemas com o espaço em branco. Estes erros podem ser dificéis de depurar por causa de espaços, tabs e novas linhas que são normalmente invisivéis.

julia> s = "1 2\t 3\n 4";

julia> println(s)
1 2     3
 4

As funções internas repr ou dump podem ajudar. Elas recebem qualquer objeto como argumento e retornam uma string representando o objeto.

julia> repr(s)
"\"1 2\\t 3\\n 4\""
julia> dump(s)
String "1 2\t 3\n 4"

Isso pode ser útil para depurar.

Um outro problema que você pode encontrar é que sistemas diferentes usam caracteres diferentes para indicar o final da linha. Alguns sistemas usam uma nova linha, representada por \n. Outros usam um caractere de retorno, representado por \r. Alguns usam ambos. Se você mover arquivos entre sistemas diferentes, essas inconsistências podem causar problemas.

Para a maioria dos sistemas, existem aplicações que convertem de um formato para o outro. Você pode achá-los (e ler mais a respeito deste problema) em https://pt.wikipedia.org/wiki/Nova_linha. Ou, é claro, você pode escrever um por conta própria.

Glossário

persistente

Referente a um programa que é executado indefinidamente e mantém pelo menos alguns de seus dados em armazenamento permanente.

arquivo de texto

Uma sequência de caracteres guardados em armazenamento permanente como um disco rígido.

diretório

Uma coleção de arquivos com nome, também chamada de pasta.

caminho

Uma string que identifica um arquivo.

caminho relativo

Um caminho que inicia no diretório atual.

caminho absoluto

Um caminho que inicia do diretório mais acima no sistema de arquivos.

catch

Prevenir uma exceção de terminar um programa usando as declarações try ... catch ... finally.

banco de dados

Um arquivo cujo conteúdo está organizado como um dicionário com chaves que correspondem a valores.

shell

Um programa que permite usuários a digitar comandos e em seguida executá-los iniciando outros programas.

objeto comando

Um objeto que representa um comando shell, permitindo um programa em Julia a executar comandos e ler os resultados.

Exercícios

Exercício 14-2

Escreva uma função chamada sed que recebe como argumento uma string padrão, uma string de substituição e dois nomes de arquivos; ela deve ler o primeiro arquivo e escrever o conteúdo no segundo arquivo (o criando se for necessário). Se o padrão aparece em qualquer lugar no arquivo, ele deve ser substituido pela string de substituição.

Se um erro ocorre durante a abertura, leitura, escrita ou fechamento dos arquivos, seu programa deve capturar a exceção, exibir a mensagem de erro e sair.

Exercício 14-3

Se você fez Exercício 12-3, você verá que um dicionário é criado que mapeia uma string ordenada de letras para uma lista de palavras que podem ser escritas com estas letras. Por exemplo, "opst" é mapeado para a lista ["opts", "post", "pots", "spot", "stop", "tops"].

Escreva um módulo que importe anagramsets e forneça duas novas funções: guardaanagramas deve guardar o dicionário de anagramas usando JLD2; leranagrams deve buscar uma palavra e retornar uma lista de seus anagramas.

Exercício 14-4

Em uma grande coleção de arquivos MP3, podem haver mais de uma cópia da mesma música, guardada em diretórios diferentes ou com nomes de arquivo diferentes. O objetivo deste exercício é procurar por duplicatas.

  1. Escreva um programa que busca um diretório e todos seus subdiretórios, recursivamente, e retorna uma lista de paths completos para todos os arquivos com um dado sufixo (como .mp3).

  2. Para reconhecer duplicatas, você pode usar md5sum ou md5 para computar a “checksum” de cada arquivo. Se dois arquivos tem a mesma checksum, eles provavelmente tem o mesmo conteúdo.

  3. Para verificar novamente, você pode usar o comando Unix diff.

15. Estruturas e Objetos

A esta altura, você sabe como usar funções para organizar o código e os tipos internos para organizar os dados. O próximo passo é aprender a criar os seus próprios tipos para organizar tanto o código como os dados. Este é um tópico importante e serão necessários alguns capítulos para abordar o tema.

Tipos Compostos

Temos usado muitos tipos internos do Julia e agora definiremos um novo tipo. Como exemplo, vamos criar um tipo chamado Ponto que representa um ponto no espaço bidimensional.

Na notação matemática, os pontos geralmente são escritos entre parênteses com uma vírgula separando as coordenadas. Por exemplo, \(\left(0,0\right)\) representa a origem e \(\left(x,y\right)\) representa o ponto \(x\) unidades à direita e \(y\) unidades acima da origem, se o ponto estiver no 1o. quadrante.

Existem diversas maneiras de representar pontos no Julia:

  • Poderíamos armazenar as coordenadas separadamente em duas variáveis, x e y.

  • Poderíamos armazenar as coordenadas como elementos de um vetor ou de uma tupla.

  • Poderíamos criar um novo tipo para representar os pontos como objetos.

Criar um novo tipo é mais complicado que as outras opções, mas possui vantagens que serão mostradas em breve.

Um tipo composto definido pelo programador também é denominado de estrutura (struct). A definição struct pode ser representada da seguinte maneira:

struct Ponto
    x
    y
end

O cabeçalho indica que a nova struct é chamada de Ponto enquanto o corpo define os atributos ou os campos da struct. Nesse caso, a struct Ponto possui dois campos: x e y.

Uma struct é como uma fábrica que cria objetos. Para criar um ponto, chama-se Ponto como se fosse uma função que tem os valores dos campos como argumentos. Quando Ponto é usado como uma função, ele é chamado de construtor.

julia> p = Ponto(3.0, 4.0)
Ponto(3.0, 4.0)

O valor de retorno é uma referência a um objeto Ponto, que atribuímos a p.

A criação de um novo objeto é chamada instanciação, e o objeto é uma instância do tipo.

Quando você imprime uma instância, o Julia informa a que tipo pertence e quais os valores dos atributos.

Todo objeto é uma instância de algum tipo; portanto, “objeto” e “instância” são permutáveis. Mas neste capítulo, eu uso “instância” para indicar que estou falando de um tipo definido pelo programador.

Um diagrama de estado que mostra um objeto e os seus campos é denominado diagrama do objeto, conforme Diagrama do objeto.

fig151
Figura 20. Diagrama do objeto

Structs são Imutáveis

Você pode acessar os valores dos campos usando a notação .:

julia> x = p.x
3.0
julia> p.y
4.0

A expressão p.x significa: “Vá até a referência do objeto p e obtenha o valor de x.” No exemplo, atribuímos esse valor a uma variável denominada x. Não há conflito entre a variável x e o campo x.

Você pode usar a notação do ponto como parte de qualquer expressão. Por exemplo:

julia> distância = sqrt(p.x^2 + p.y^2)
5.0

Por padrão, as structs são imutáveis, isto é, após a construção, os campos não podem mudar de valor:

julia> p.y = 1.0
ERROR: setfield! immutable struct of type Ponto cannot be changed

Isso pode parecer estranho de início, mas tem várias vantagens:

  • Pode ser mais eficiente.

  • Não é possível violar os invariantes dos construtores do tipo (veja Construtores).

  • O código que usa os objetos imutáveis pode ser mais fácil de ler e entender.

Structs Mutáveis

Quando necessário, os tipos compostos mutáveis podem ser declarados com a palavra-chave mutable struct. Aqui está a definição de um ponto mutável:

mutable struct MPonto
    x
    y
end

Você pode atribuir valores a uma instância de uma struct mutável usando a notação de ponto:

julia> lacuna = MPonto(0.0, 0.0)
MPonto(0.0, 0.0)
julia> lacuna.x = 3.0
3.0
julia> lacuna.y = 4.0
4.0

Retângulos

Às vezes, é óbvio quais devem ser os campos de um objeto, mas outras vezes você precisa escolher entre as opções. Por exemplo, imagine que você está estruturando um tipo para representar retângulos. Quais campos você usaria para especificar a localização e o tamanho de um retângulo? Desconsidere o ângulo e para simplificar, suponha que o retângulo seja vertical ou horizontal.

Existem pelo menos duas possibilidades:

  • Você poderia especificar um vértice do retângulo (ou o centro), a largura e a altura.

  • Você poderia especificar dois vértices opostos.

Nesse momento, é difícil dizer se uma é melhor que a outra e portanto, implementaremos a primeira, apenas como exemplo.

"""
Representa um retângulo.

campos: largura, altura, vértice.
"""
struct Retângulo
    largura
    altura
    vértice
end

O docstring lista os campos: a largura e a altura, que são números, e o vértice, que é um objeto Ponto que indica o vértice inferior esquerdo.

Para representar um retângulo, você precisa instanciar um objeto Retângulo:

julia> origem = MPonto(0.0, 0.0)
MPonto(0.0, 0.0)
julia> caixa = Retângulo(100.0, 200.0, origem)
Retângulo(100.0, 200.0, MPonto(0.0, 0.0))

Diagrama do objeto mostra o estado deste objeto. Um objeto que é um campo de outro objeto é embutido. Perceba que o atributo vértice se refere a um objeto mutável, por isso ele é desenhado fora do objeto Retângulo.

fig152
Figura 21. Diagrama do objeto

Instâncias como Argumentos

Você pode passar uma instância como um argumento da maneira tradicional. Por exemplo:

function imprimir_ponto(p)
    println("($(p.x), $(p.y))")
end

A função imprimir_ponto recebe como argumento um Ponto e apresenta-o em notação matemática. Para chamá-lo, você pode passar p como argumento:

julia> imprimir_ponto(lacuna)
(3.0, 4.0)
Exercício 15-1

Escreva uma função chamada distância_entre_pontos que recebe dois pontos como argumentos e retorna a distância entre eles.

Se um objeto da struct mutável for passado para uma função como argumento, a função poderá modificar os campos do objeto. Por exemplo, move_ponto! recebe um objeto mutável MPonto e dois números dx e dy, e adiciona os números respectivamente aos atributos x e y de MPonto:

function move_ponto!(p, dx, dy)
    p.x += dx
    p.y += dy
    nothing
end

Aqui está um exemplo que mostra o resultado:

julia> origem = MPonto(0.0, 0.0)
MPonto(0.0, 0.0)
julia> move_ponto!(origem, 1.0, 2.0)

julia> origem
MPonto(1.0, 2.0)

Dentro da função, p é um alias (ou uma referência) para origem, então quando a função modifica p, origem também muda.

Passar um objeto Ponto imutável para move_ponto! gera um erro:

julia> move_ponto!(p, 1.0, 2.0)
ERROR: setfield! immutable struct of type Ponto cannot be changed

No entanto, você pode modificar o valor de um atributo mutável de um objeto imutável. Por exemplo, move_retângulo! tem como argumentos um objeto Retângulo e dois números dx e dy, e usa move_ponto! para mover o canto do retângulo:

function move_retângulo!(ret, dx, dy)
  move_ponto!(ret.vértice, dx, dy)
end

Agora p em move_ponto! é uma referência para ret.vértice, então quando p é modificado, ret.vértice também muda:

julia> caixa
Retângulo(100.0, 200.0, MPonto(0.0, 0.0))
julia> move_retângulo!(caixa, 1.0, 2.0)

julia> caixa
Retângulo(100.0, 200.0, MPonto(1.0, 2.0))
Atenção

Você não pode reatribuir um atributo mutável a um objeto imutável:

julia> caixa.vértice = MPonto(1.0, 2.0)
ERROR: setfield! immutable struct of type Retângulo cannot be changed

Instâncias como Valores de Retorno

Funções podem retornar instâncias. Por exemplo, encontra_centro recebe um Retângulo como argumento e retorna um Ponto que contém as coordenadas do centro do retângulo:

function encontra_centro(ret)
    Ponto(ret.vértice.x + ret.largura / 2, ret.vértice.y + ret.altura / 2)
end

A expressão ret.vértice.x significa: “Vá ao objeto ret e selecione o campo vértice; depois vá até esse objeto e selecione o campo x.”

Aqui está um exemplo que passa caixa como argumento e atribui o Ponto recebido ao centro:

julia> centro = encontra_centro(caixa)
Ponto(51.0, 102.0)

Copiando Objetos

Usar um alias pode dificultar a leitura de um programa, pois as alterações em um local podem ter efeitos inesperados em outro local. É difícil acompanhar todas as variáveis que podem se referir a um objeto dado.

A cópia de um objeto é muitas vezes uma alternativa ao aliasing. O Julia possui uma função chamada deepcopy que pode duplicar qualquer objeto:

julia> p1 = MPonto(3.0, 4.0)
MPonto(3.0, 4.0)
julia> p2 = deepcopy(p1)
MPonto(3.0, 4.0)
julia> p1 ≡ p2
false
julia> p1 == p2
false

O operador indica que p1 e p2 não são o mesmo objeto, que é o que imaginávamos. Mas você pode ter pensado que == devolvesse true porque esses pontos contêm os mesmos dados. Nesse caso, você ficará desapontado ao saber que, para objetos mutáveis, o comportamento padrão do operador == é o mesmo do operador === pois verifica-se a identidade do objeto e não a equivalência do objeto. Isso ocorre porque, para tipos compostos mutáveis, o Julia não sabe o que deve ser considerado equivalente. Pelo menos, ainda não.

Exercício 15-2

Crie uma instância Ponto, faça uma cópia dela e verifique a equivalência e a igualdade de ambas. O resultado pode surpreendê-lo, além de explicar porque o alias não é um problema para um objeto imutável.

Depuração

Quando você começa a trabalhar com os objetos, é provável que encontre algumas novas exceções. Se você tentar acessar um campo que não existe, tem-se:

julia> p = Ponto(3.0, 4.0)
Ponto(3.0, 4.0)
julia> p.z = 1.0
ERROR: type Ponto has no field z

Se você não tem certeza de qual é o tipo de objeto, pode-se perguntar:

julia> typeof(p)
Ponto

Você também pode usar isa para verificar se um objeto é uma instância de um certo tipo:

julia> p isa Ponto
true

Se você não tem certeza se um objeto possui um determinado atributo, pode-se usar a função interna fieldnames:

julia> fieldnames(Ponto)
(:x, :y)

ou a função isdefined:

julia> isdefined(p, :x)
true
julia> isdefined(p, :z)
false

O primeiro argumento pode ser qualquer objeto enquanto o segundo argumento é um símbolo : seguido do nome do campo.

Glossário

struct

Um tipo composto.

construtor

Uma função que tem o mesmo nome que um tipo e que cria as instâncias deste tipo.

instância

Um objeto que pertence a um tipo.

instanciar

Criar um novo objeto.

atributo ou campo

Um dos valores nomeados associados a um objeto.

objeto embutido

Um objeto que é armazenado como um campo de outro objeto.

cópia profunda

Cópia do conteúdo de um objeto, bem como quaisquer objetos embutidos e quaisquer objetos embutidos a eles, e assim por diante; é implementado pela função deepcopy.

diagrama de objetos

Um diagrama que mostra os objetos, os seus campos e os respectivos valores dos campos.

Exercícios

Exercício 15-3
  1. Escreva uma definição para um tipo chamado Círculo com os campos centro e raio, em que centro é um objeto Ponto e raio é um número.

  2. Instancie um objeto círculo que represente um círculo com seu centro em \(\left(150, 100\right)\) e raio 75.

  3. Escreva uma função denominada ponto_no_círculo que recebe um objeto Círculo e um objeto Ponto e retorna true se o ponto estiver dentro ou no contorno do círculo.

  4. Escreva uma função denominada ret_no_círculo que recebe um objeto Círculo e um objeto Retângulo e retorna true se o retângulo estiver inteiramente dentro ou nos contornos do círculo.

  5. Escreva uma função denominada ret_círc_sobreposição que recebe um objeto Círculo e um objeto Retângulo e devolve true se algum dos vértices do retângulo estiver dentro do círculo. Ou, como uma versão mais desafiadora, devolva true se alguma parte do retângulo estiver dentro do círculo.

Exercício 15-4
  1. Escreva uma função chamada desenha_ret que recebe um objeto do tipo turtle e um objeto Retângulo e use a tartaruga para desenhar o retângulo. Verifique o Capítulo 4 para os exemplos que usam os objetos Turtle.

  2. Escreva uma função chamada desenha_círculo que recebe um objeto Turtle e um objeto Círculo e desenha o círculo.

16. Estruturas e Funções

Agora que sabemos como criar novos tipos compostos, o próximo passo é escrever funções que recebem objetos definidos pelo programador como parâmetro e retornam estes objetos como resultado. Neste capítulo apresentaremos um “estilo de programação funcional” e dois novos planos de desenvolvimento de programa.

Horário

Como outro exemplo de um tipo composto, definiremos uma struct chamada MeuHorário que guarda o horário do dia. A definição da estrutura se parece como:

"""
Representa o horário do dia.

campos: hora, minuto, segundo
"""
struct MeuHorário
    hora
    minuto
    segundo
end

O nome Time já é utilizado no Julia e para evitar um conflito de nomes, escolhemos MeuHorário. Podemos criar um novo objeto MeuHorário:

julia> horario = MeuHorário(11, 59, 30)
MeuHorário(11, 59, 30)

O diagrama de objeto para MeuHorário se parece com a Diagrama de objeto.

fig161
Figura 22. Diagrama de objeto
Exercício 16-1

Escreva uma função chamada imprime_horário que recebe um objeto MeuHorário e imprima-o em forma de hora:minuto:segundo. A instrução @printf do módulo StdLib Printf imprime um inteiro com o formato de sequência "%02d" usando pelo menos dois dígitos, incluindo um zero inicial se necessário.

Exercício 16-2

Escreva uma função booleana chamada é_depois que recebe dois objetos MeuHorário, t1 e t2, e retorna true se t1 segue t2 cronologicamente e false caso contrário. Desafio: não use uma declaração if.

Funções Puras

Nas próximas seções, escreveremos duas funções que adicionam valores de tempo. Elas demonstrarão dois tipos de funções: funções puras e modificadoras. Elas também demonstrarão um plano de desenvolvimento que chamaremos de protótipo e correção, que é uma forma de combater um problema complexo começando com um protótipo simples e aos poucos lidar com as complicações.

Aqui vai um simples protótipo de soma_horário:

function soma_horário(t1, t2)
    MeuHorário(t1.hora + t2.hora, t1.minuto + t2.minuto, t1.segundo + t2.segundo)
end

A função cria um novo objeto MeuHorário, inicializando os seus campos e retornando uma referência ao novo objeto. Isso é chamado de função pura pois não modifica nenhum dos objetos que são passados como argumentos e não tem efeito, como exibir um valor ou obter entrada do usuário, além de retornar um valor.

Para testar essa função, criaremos dois objetos MeuHorário: início que contém a hora de início de um filme, como Monthy Python: Em Busca do Cálice Sagrado, e duração que contém o tempo de duração do filme, que é de uma hora e 35 minutos.

soma_horário descobre quando o filme irá terminar.

julia> início = MeuHorário(9, 45, 0);

julia> duração = MeuHorário(1, 35, 0);

julia> fim = soma_horário(início, duração);

julia> imprime_horário(fim)
10:80:00

O resultado, 10:80:00 talvez não seja o que esperávamos. O problema é que essa função não lida com casos no qual o número de segundos ou minutos ultrapassam sessenta. Quando isso ocorre, temos que “carregar” os segundos extras para a coluna de minutos ou os minutos extras para a coluna de horas. Aqui vai uma versão aprimorada:

function soma_horário(t1, t2)
    segundo = t1.segundo + t2.segundo
    minuto = t1.minuto + t2.minuto
    hora = t1.hora + t2.hora
    if segundo >= 60
        segundo -= 60
        minuto += 1
    end
    if minuto >= 60
        minuto -= 60
        hora += 1
    end
    MeuHorário(hora, minuto, segundo)
end

Apesar desta função estar correta, ela está começando a ficar grande. Veremos uma alternativa mais concisa em breve.

Modificadores

Em certos momentos, é útil para uma função modificar os objetos que recebe como parâmetros. Neste caso, as mudanção são visíveis para quem a chama. Funções que funcionam dessa maneira são chamadas de modificadoras.

incrementa!, que adiciona um dado número de segundos à um objeto MeuHorário, pode ser escrito naturalmente como um modificador. Aqui está um rascunho:

function incrementa!(tempo, segundos)
    tempo.segundo += segundos
    if tempo.segundo >= 60
        tempo.segundo -= 60
        tempo.minuto += 1
    end
    if tempo.minuto >= 60
        tempo.minuto -= 60
        tempo.hora += 1
    end
end

A primeira linha executa a operação básica, o resto lida com os casos especiais que vimos anteriormente.

Essa função está correta? O que acontece se segundos for muito maior que 60?

Neste caso, não basta carregar uma vez; temos que continuar a fazer isso até que tempo.segundo seja menor que 60. Uma solução é substituir as declarações if por declarações while. O que fará com que a função fique correta, porém, não muito eficiente.

Exercício 16-3

Escreva uma versão correta de incrementa! que não contenha nenhum laço.

Qualquer coisa que pode ser feita com modificadores também pode ser feita com funções puras. De fato, algumas linguagens de programação permitem apenas funções puras. Há algumas evidências de que programas que usam funções puras são mais rápidos para serem desenvolvidos e menos propensos a erros do que programas que usam modificadores. Porém, às vezes modificadores são convenientes e programas funcionais tendem a ser menos eficientes.

Em geral, recomendamos que você escreva funções puras sempre que for razoável e recorrer a modificadores apenas se há uma vantagem atraente. Essa abordagem pode ser chamada de estilo de programação funcional.

Exercício 16-4

Escreva uma versão “pura” de incrementa que cria e retorna um novo objeto MeuHorário ao invéz de modificar o parâmetro.

Prototipagem Versus Planejamento

O plano de desenvolvimento que estamos demonstrando é chamado de “protótipo e correção”. Para cada função, escrevemos um protótipo que executava os cálculos básicos e depois testavamos-o, corrigindo os erros ao longo do caminho.

Essa abordagem pode ser efetiva, especialmente quando você ainda não tem um entendimento profundo acerca do problema. Mas correções incrementais podem gerar código que é desnecessariamente complicado, já que ele lida com muitos casos especiais, e também que não é confiável, já que é difícil saber se você encontrou todos os erros.

Uma alternativa é o desenvolvimento projetado, no qual obter uma visão de alto nível do problema pode facilitar muito a programação. Neste caso, podemos perceber que um objeto Time é na verdade um número de três dígitos na base 60 (consulte https://pt.wikipedia.org/wiki/Sistema_de_numera%C3%A7%C3%A3o_sexagesimal)! O atributo dos segundos é a “coluna de uns”, o atributo de munutos é a “coluna de sessentas” e o atributo da hora é a “coluna de três mil e seiscentos”.

Quando escrevemos soma_horário e incrementa!, efetivamente estavamos realizando uma adição na base 60, que é a razão de termos carregado de uma coluna para a próxima.

Essa observação nos sugere uma outra abordagem para todo o problema—podemos converter objetos MeuHorário para inteiros e obter uma vantagem do fato de que o computador sabe como realizar artimética inteira.

Abaixo temos uma função que converte um objeto MeuHorário para inteiros.

function hora_para_int(tempo)
    minutos = tempo.hora * 60 + tempo.minuto
    segundos = minutos * 60 + tempo.segundo
end

Aqui temos uma função que converte um inteiro para MeuHorário (lembre-se que divrem divide o primeiro argumento pelo segundo e retorna o quociente e o resto como uma tupla):

function int_para_hora()
    (minutos, segundo) = divrem(segundos, 60)
    hora, minuto = divrem(minutos, 60)
    MeuHorário(hora, minuto, segundo)
end

Talvez você tenha que pensar um pouco e executar alguns testes para se convencer de que essas funções estão corretas. Uma forma de testar é verificar que hora_para_int(int_para_hora(x)) == x para quaisquer valores de x. Esse é um exemplo de verificação de consistência.

Uma vez que você esteja convencido de que elas estão corretas, você poderá usá-las para reescrever soma_horário

function soma_horário(t1, t2)
    segundos = hora_para_int(t1) + hora_para_int(t2)
    int_para_hora(segundos)
end

Essa versão é mais curta que a original e mais fácil de verificar.

Exercício 16-5

Reescreva incrementa! usando hora_para_int e int_para_hora.

Algumas vezes, converter da base 60 para a base 10 e vice-versa é mais difícil do que lidar com tempo. A conversão de base é mais abstrata; a nossa intuição para lidar com valores de tempo é melhor.

Mas se nós tivermos a ideia de tratar horas como número de base 60 e investir na escrita de funções de conversão (hora_para_int e int_para_hora), nós temos um programa que é menor, mais fácil de ler e depurar, e mais confiável.

Também é mais fácil acrescentar característica depois. Por exemplo, imagine subtrair dois objetos MeuHorário para encontrar a duração entre eles. Uma abordagem ingênua seria implementar a subtração com empréstimo. Porém, usar funções de conversão seria mais fácil e, provavelmente, mais correto.

Ironicamente, as vezes tornar um problema mais difícil (ou mais geral) facilita (porque há menos casos especiais e menos oportunidades de erro).

Depurando

Um objeto MeuHorário é bem formado se os valores de minuto e segundo estão entre 0 e 60 (incluindo 0 mas não 60) e se hora é positivo. hora e minuto devem ser valores integrais mas talvez devessemos permitir que segundo tenha uma parte fracional.

Requisitos como esses são ditos invariantes porque eles sempre devem ser verdadeiros. Para dizer de outra forma, se eles não forem verdadeiros, algo deu errado.

Escrever código para verificar requisitos invariantes pode ajudar a descobrir erros e encontrar suas causas. Por exemplo, você pode ter uma função como hora_válida, que receba um objeto MeuHorário e retorna false se ela violar um requisito invariante:

function hora_válida(tempo)
    if tempo.hora < 0 || tempo.minuto < 0 || tempo.segundo < 0
        return false
    end
    if tempo.minuto >= 60 || tempo.segundo >= 60
        return false
    end
    true
end

No início de cada função você deve checar os argumentos para ter certeza de que eles são válidos.

function soma_horário(t1,t2)
    if !hora_válida(t1) || !hora_válida(t2)
        error("objeto MeuHorário inválido em soma_horário")
    end
    segundos = hora_para_int(t1) + hora_para_int(t2)
   int_para_hora(segundos)
end

Ou você pode usar uma instrução @assert, que verifica determinado requisito invariável e gera uma exceção se ele falhar:

function soma_horário(t1, t2)
    @assert(hora_válida(t1) && hora_válida(t2), "objeto MeuHorário inválido em soma_horário")
    segundos = hora_para_int(t1) + hora_para_int(t2)
    int_para_hora(segundos)
end

Macros @assert são úteis porque distinguem o código que lida com condições normais do código que verifica erros.

Glossário

protótipo e correção

Um plano de desenvolvimento que envolve escrever um rascunho de um programa, testar e corrigir erros que são encontrados.

desenvolvimento projetado

Plano de desenvolvimento que implica uma compreensão de alto nível do problema e mais planejamento do que desenvolvimento incremental ou desenvolvimento prototipado.

funções puras

Função que não altera nenhum dos objetos que recebe como argumento. A maior parte das funções puras gera resultado.

modificador

Função que modifica um ou vários dos objetos que recebe como argumento. A maior parte dos modificadores são nulos; isto é, retornam nothing.

programa funcional

Um estilo de projeto de programa no qual a maioria das funções são puras.

invariante

Uma condição que nunca deve mudar durante a execução de um programa.

Exercícios

Exercício 16-6

Escreva uma função chamada mult_horário que pega um objeto MeuHorário e um número, e retorna um novo objeto MeuHorário que contém o produto do MeuHorário original e do número.

Em seguida use mult_horário para escrever uma função que receba um objeto MeuHorário representando o tempo até o fim de uma corrida e um número que represente a distância e retorne um objeto MeuHorário com o ritmo médio (tempo por quilômetro).

Exercício 16-7

O Julia fornece objetos de tempo que são similares aos objetos MeuHorário desse capítulo, mas eles fornecem um conjunto rico de funções e operadores. Leia a documentação em https://docs.julialang.org/en/v1/stdlib/Dates/.

  1. Escreva um programa que obtenha a data atual e imprima o dia da semana.

  2. Escreva um programa que aceite um aniversário como entrada e imprima a idade do usuário e o número de dias, horas, minutos e segundos até o próximo aniversário.

  3. Para duas pessoas nascidas em dias diferentes, há um dia em que uma tem o dobro da idade da outra. Esse é o Dia do Dobro deles. Escreva um programa que receba dois aniversários e calcula o Dia do Dobro.

  4. Para lhe desafiar um pouco, escreva a versão mais geral que calcula o dia em que uma pessoa é \(n\) vezes mais velha que a outra.

17. Despacho Múltiplo

No Julia você tem a habilidade de escrever código que opera em tipos diferentes. Esta habilidade é chamada de programação genérica.

Neste capítulo, discutiremos o uso de declarações de tipo em Julia, e também introduziremos métodos que implementam os comportamentos diferentes para uma função, dependendo dos tipos de seus argumentos. Isto é chamado de despacho múltiplo.

Declarações de Tipo

O operador :: anexa anotações de tipo às expressões e variáveis:

julia> (1 + 2) :: Float64
ERROR: TypeError: in typeassert, expected Float64, got Int64
julia> (1 + 2) :: Int64
3

Isso ajuda a confirmar que o seu programa funciona do jeito esperado.

O operador :: também pode ser acrescentado ao fim do lado esquerdo de uma atribuição, ou como uma parte de uma declaração.

julia> function retorna_float()
           x::Float64 = 100
           x
       end
retorna_float (generic function with 1 method)
julia> x = retorna_float()
100.0
julia> typeof(x)
Float64

A variável x é sempre do tipo Float64 e o valor é convertido para um ponto flutuante, quando necessário.

Uma anotação de tipo também pode ser anexada ao cabeçalho de uma definição de função:

function sinc(x)::Float64
    if x == 0
        return 1
    end
    sin(x)/(x)
end

O valor de retorno de sinc é sempre convertido para o tipo Float64.

Quando os tipos são omitidos, o comportamento padrão em Julia é permitir que os valores sejam de qualquer tipo (Any).

Métodos

Em Estruturas e Funções, nós definimos uma struct chamada MeuHorário e em Horário, você escreveu uma função chamada imprima_horário:

using Printf

struct MeuHorário
    hora :: Int64
    minuto :: Int64
    segundo :: Int64
end

function imprima_horário(tempo)
    @printf("%02d:%02d:%02d", tempo.hora, tempo.minuto, tempo.segundo)
end

Como você pode ver, as declarações de tipo podem e devem ser adicionadas nos campos da definição da struct por questões de performance.

Para chamar esta função, você deve passar um objeto MeuHorário como um argumento:

julia> início = MeuHorário(9, 45, 0)
MeuHorário(9, 45, 0)
julia> imprima_horário(início)
09:45:00

Para adicionar um método à função imprima_horário que apenas aceita um objeto MeuHorário como argumento, tudo que precisamos fazer é acrescentar :: seguido de MeuHorário no final do argumento tempo na definição de função:

function imprima_horário(tempo::MeuHorário)
    @printf("%02d:%02d:%02d", tempo.hora, tempo.minuto, tempo.segundo)
end

Um método é uma definição de função com uma assinatura específica: imprima_horário possui um argumento do tipo MeuHorário.

Chamar a função imprima_horário com um objeto MeuHorário produz o mesmo resultado:

julia> imprima_horário(início)
09:45:00

Nós podemos agora redefinir o primeiro método sem a anotação de tipo ::, permitindo um argumento de qualquer tipo:

function imprima_horário(tempo)
    println("Eu não sei como imprimir o argumento tempo.")
end

Se você chamar a função imprima_horário com um objeto diferente de MeuHorário, agora recebe-se:

julia> imprima_horário(150)
Eu não sei como imprimir o argumento tempo.
Exercício 17-1

Reescreva horário_para_int e int_para_horário (de Prototipagem Versus Planejamento) para especificar os seus argumentos.

Exemplos Adicionais

Aqui está uma versão de incrementa (de Modificadores) reescrita para especificar seus argumentos:

function incrementa(tempo::MeuHorário, segundos::Int64)
    segundos += horário_para_int(tempo)
    int_para_horário(segundos)
end

Note que agora ela é uma função pura, e não um modificador.

Você pode invocar incrementa da seguinte maneira:

julia> início = MeuHorário(9, 45, 0)
MeuHorário(9, 45, 0)
julia> incrementa(início, 1337)
MeuHorário(10, 7, 17)

Se você colocar os argumentos na ordem errada, um erro é gerado:

julia> incrementa(1337, início)
ERROR: MethodError: no method matching incrementa(::Int64, ::MeuHorário)

A assinatura do método é incrementa(tempo::MeuHorário, segundos::Int64) e não incrementa(segundos::Int64, tempo::MeuHorário).

Reescrever é_depois para operar somente com objetos MeuHorário é fácil:

function é_depois(t1::MeuHorário, t2::MeuHorário)
    (t1.hora, t1.minuto, t1.segundo) > (t2.hora, t2.minuto, t2.segundo)
end

Aliás, os argumentos opcionais são implementados como sintaxe para as múltiplas definições do método. Por exemplo, essa definição:

function f(a=1, b=2)
    a + 2b
end

é equivalente aos seguintes três métodos:

f(a, b) = a + 2b
f(a) = f(a, 2)
f() = f(1, 2)

Estas expressões são definições de método válidas em Julia. E é uma notação mais enxuta para definir funções/métodos.

Construtores

Um construtor é uma função especial chamada para criar um objeto. Os métodos construtores padrões de MeuHorário têm a seguinte assinatura:

MeuHorário(hora, minuto, segundo)
MeuHorário(hora::Int64, minuto::Int64, segundo::Int64)

Nós podemos também adicionar os nossos próprios métodos construtores externos:

function MeuHorário(tempo::MeuHorário)
    MeuHorário(tempo.hora, tempo.minuto, tempo.segundo)
end

Esse método é chamado de construtor cópia pois o novo objeto MeuHorário é uma cópia do seu argumento.

Para impor as invariantes, nós precisamos de métodos construtores internos:

struct MeuHorário
    hora :: Int64
    minuto :: Int64
    segundo :: Int64
    function MeuHorário(hora::Int64=0, minuto::Int64=0, segundo::Int64=0)
        @assert(0 ≤ minuto < 60, "Minuto não está entre 0 e 60.")
        @assert(0 ≤ segundo < 60, "Segundo não está entre 0 e 60.")
        new(hora, minuto, segundo)
    end
end

A struct MeuHorário agora tem 4 métodos construtores internos:

MeuHorário()
MeuHorário(hora::Int64)
MeuHorário(hora::Int64, minuto::Int64)
MeuHorário(hora::Int64, minuto::Int64, segundo::Int64)

Um método construtor interno é sempre definido dentro do bloco de uma declaração de tipo e tem acesso a uma função especial chamada new que cria os objetos de um novo tipo declarado.

Atenção

O construtor padrão não é disponibilizado se qualquer construtor interno for definido. Você deve escrever explicitamente todos os construtores internos de que você precisa.

Um segundo método sem argumentos da função local new existe:

mutable struct MeuHorário
    hora :: Int
    minuto :: Int
    segundo :: Int
    function MeuHorário(hora::Int64=0, minuto::Int64=0, segundo::Int64=0)
        @assert(0 ≤ minuto < 60, "Minuto está entre 0 e 60.")
        @assert(0 ≤ segundo < 60, "Segundo está entre 0 e 60.")
        tempo = new()
        tempo.hora = hora
        tempo.minuto = minuto
        tempo.segundo = segundo
        tempo
    end
end

Isso permite a criação das estruturas de dados recorrentes, isto é, uma struct no qual um dos campos é a própria struct. Neste caso, a struct precisa ser mutável pois os seus campos são modificados após serem instanciados.

show

show é uma função especial que retorna uma representação de string de um objeto. Por exemplo, aqui está um método show para os objetos MeuHorário:

using Printf

function Base.show(io::IO, tempo::MeuHorário)
    @printf(io, "%02d:%02d:%02d", tempo.hora, tempo.minuto, tempo.segundo)
end

O prefixo Base é necessário pois nós queremos adicionar um novo método à função Base.show.

Quando você imprime um objeto, o Julia invoca a função show:

julia> tempo = MeuHorário(9, 45)
09:45:00

Quando eu crio um novo tipo composto, eu quase sempre começo criando um construtor externo, que facilita a instanciação dos objetos, e show, que é útil para a depuração.

Exercício 17-2

Escreva um método construtor externo para a classe Ponto que recebe x e y como parâmetros adicionais e que são atribuídos aos campos correspondentes.

Sobrecarga de Operador

Ao definir métodos para os operadores, você pode especificar o comportamento dos operadores em tipos definidos pelo programador. Por exemplo, ao definir um método chamado + com dois argumentos MeuHorário, você pode usar o operador + em objetos MeuHorário.

A definição deve se parecer com algo como:

import Base.+

function +(t1::MeuHorário, t2::MeuHorário)
    segundos = horário_para_int(t1) + horário_para_int(t2)
    int_para_horário(segundos)
end

A declaração import adiciona o operador + ao escopo local para que os métodos possam ser adicionados.

E você poderia usá-lo como:

julia> início = MeuHorário(9, 45)
09:45:00
julia> duração = MeuHorário(1, 35, 0)
01:35:00
julia> início + duração
11:20:00

Quando você aplica o operador + aos objetos MeuHorário, o Julia invoca o novo método adicionado. Quando o REPL mostra o resultado, o Julia invoca show. Então muita coisa acontece por trás das cortinas!

Adicionar ao comportamento de um operador para que funcione com tipos definidos pelo programador é chamado de sobrecarga de operador.

Despacho Múltiplo

Na seção anterior, nós adicionamos dois objetos MeuHorário, mas você também pode adicionar um inteiro ao objeto MeuHorário:

function +(tempo::MeuHorário, segundos::Int64)
    incrementa(tempo, segundos)
end

Aqui está um exemplo que usa o operador + com um objeto MeuHorário e um inteiro:

julia> início = MeuHorário(9, 45)
09:45:00
julia> início + 1337
10:07:17

Adição é um operador comutativo, por isso temos que adicionar outro método.

function +(segundos::Int64, tempo::MeuHorário)
  tempo + segundos
end

E nós obtemos o mesmo resultado:

julia> 1337 + início
10:07:17

A escolha de qual método executar quando a função é aplicada é chamada de despacho. O Julia permite que o processo de despacho escolha qual método de uma função chamar baseado no número de argumentos dados, e nos tipos de todos os argumentos da função. Usar todos os argumentos de uma função para escolher qual método deve ser invocado é conhecido como despacho múltiplo.

Exercício 17-3

Escreva métodos + para os objetos ponto:

  • Se ambos operandos são objetos ponto, o método deve retornar um novo objeto ponto cuja coordenada x é a soma das coordenadas x dos operandos, e deve proceder da mesma forma para as coordenadas y.

  • Se o primeiro ou o segundo operando é uma tupla, o método deve somar o primeiro elemento da tupla à coordenada x e o segundo elemento à coordenada y, e retornar um novo objeto ponto com o resultado.

Programação Genérica

O despacho múltiplo é útil quando é necessário, apesar de (felizmente) ele não ser sempre necessário. Muitas vezes você pode evitá-lo escrevendo funções que funcionam corretamente para os argumentos com os tipos diferentes.

Muitas das funções que nós escrevemos para as strings também funcionam para os outros tipos de sequência. Por exemplo, em Dicionários como uma Coleção de Contadores nós usamos histograma para contar o número de vezes de cada letra que aparece em uma palavra.

function histograma(s)
    d = Dict()
    for c in s
        if c ∉ keys(d)
            d[c] = 1
        else
            d[c] += 1
        end
    end
    d
end

Esta função também funciona para as listas, as tuplas, e até mesmo os dicionários, contanto que para os elementos de s exista uma função hash, para que eles possam ser usados como chaves em d.

julia> t = ("presunto", "ovo", "presunto", "presunto", "bacon", "presunto")
("presunto", "ovo", "presunto", "presunto", "bacon", "presunto")
julia> histograma(t)
Dict{Any,Any} with 3 entries:
  "bacon"    => 1
  "presunto" => 4
  "ovo"      => 1

Funções que funcionam com vários tipos são chamadas de polimórficas. Polimorfismo pode facilitar o reuso de código.

Por exemplo, a função embutida sum, que soma os elementos de uma sequência, funciona contanto que os elementos da sequência possam realizar a adição.

Já que um método + é fornecido para os objetos MeuHorário, eles funcionam com sum:

julia> t1 = MeuHorário(1, 7, 2)
01:07:02
julia> t2 = MeuHorário(1, 5, 8)
01:05:08
julia> t3 = MeuHorário(1, 5, 0)
01:05:00
julia> sum((t1, t2, t3))
03:17:10

Em geral, se todos as operações dentro da função funcionam com um dado tipo, a função funciona com qualquer tipo.

O melhor tipo de polimorfismo é o tipo não intencional, no qual se descobre que uma função que você escreveu pode ser aplicada a um tipo que você nunca planejou.

Interface e Implementação

Um dos objetivos do despacho múltiplo é facilitar a manutenção do software, o que significa que você pode manter o programa funcionando quando as outras partes do sistema mudam, e modificar o programa para cumprir novos requisitos.

Um princípio de design que ajuda alcançar esse objetivo é manter as interfaces separadas das implementações. Isto significa que os métodos que possuem os argumentos denotados com um tipo não devem depender de como os campos daquele tipo são representados.

Por exemplo, neste capítulo nós desenvolvemos uma struct que representa um horário do dia. E os métodos que possuem argumentos indicados com este tipo incluem horário_para_int, é_depois e +.

Nós poderíamos implementar estes métodos de muitas maneiras. Os detalhes da implementação dependem de como representamos MeuHorário. Neste capítulo, os campos de um objeto MeuHorário são hora, minuto e segundo.

Como uma alternativa, nós poderiamos substituir estes campos com um único inteiro representando o número de segundos a partir da meia-noite. Esta implementação faria com que algumas funções, como +é_depois, sejam mais facéis de escrever, mas também faz com que outras funções sejam mais dificéis.

Depois de configurar um novo tipo, você pode descobrir uma implementação melhor. Se outras partes do programas estão usando o seu tipo, pode ser que mudar a interface consuma muito tempo e esteja sujeita a erros.

Mas se você tivesse projetado a interface com cuidado, pode-se mudar a implementação sem mudar a interface, o que significa que outras partes do programa não precisam ser alteradas.

Depuração

Chamar uma função com os argumentos corretos pode ser difícil quando mais de um método para a função é específicada. O Julia permite examinar as assinaturas dos métodos de uma função.

Para saber quais os métodos disponíveis para uma dada função, você pode usar a função methods:

julia> methods(imprima_horário)
# 2 methods for generic function "imprima_horário":
[1] imprima_horário(tempo::MeuHorário) in Main at REPL[3]:2
[2] imprima_horário(tempo) in Main at REPL[4]:2

Neste exemplo, a função imprima_horário tem 2 métodos: um com o argumento MeuHorário e um com o argumento Any.

Glossário

anotação de tipo

O operador :: seguido por um tipo indicando que a expressão ou variável é daquele tipo.

método

Uma definição de um possível comportamento para uma função.

despacho

A escolha de qual método executar quando uma função é executada.

assinatura

O número e tipo dos argumentos de um método permitindo o despacho escolher o método mais específico de uma função durante uma chamada da função.

construtor externo

Um construtor definido fora da definição de tipo para indicar os métodos convenientes para a criação de um objeto.

construtor interno

Um construtor definido dentro da definição de tipo para impor as invariantes ou para construir os objetos que referem a si mesmos.

construtor padrão

Um construtor interno que está disponível quando nenhum construtor interno definido pelo programador é fornecido.

construtor cópia

Um método construtor externo de um tipo que tem como único argumento um objeto daquele tipo. Ele cria um novo objeto que é uma cópia do seu argumento.

sobrecarga de operador

Adicionar a um comportamento de um operador como + para que funcione com um tipo definido pelo programador.

despacho múltiplo

Despacho baseado em todos os argumentos de uma função.

programação genérica

Escrever código que pode funcionar com mais de um tipo.

Exercícios

Exercício 17-4

Mude os campos de MeuHorário para ter apenas um único campo representando os segundos passados após a meia-noite. Em seguida modifique os métodos definidos neste capítulo para funcionar com a nova implementação.

Exercício 17-5

Escreva uma definição para um tipo chamado Canguru com um campo chamado conteúdo_bolso do tipo Array e os seguintes métodos:

  • Um construtor que inicializa conteúdo_bolso com uma lista vazia.

  • Um método chamado coloca_no_bolso que recebe um objeto Canguru e um objeto de qualquer tipo e adiciona-o a conteúdo_bolso.

  • Um método show que retorna uma representação string de um objeto Canguru e o conteúdo do bolso.

Teste seu código criando dois objetos Canguru, atribuindo-os a variáveis chamadas cangu e ru, e em seguida adicionando ru ao conteúdo do bolso de cangu.

18. Subtipagem

No capítulo anterior, introduzimos o mecanismo de despacho múltiplo e o polimorfismo. A ausência da especificação dos tipos dos argumentos resulta em um método que pode ser chamado com argumentos de qualquer tipo. A especificação de um subconjunto dos tipos permitidos na assinatura de um método é um próximo passo lógico.

Neste capítulo, demonstramos a subtipagem utilizando os tipos que representam as cartas de jogo, os baralhos de cartas e as mãos de pôquer.

Se você não joga pôquer, pode ler sobre isso em https://pt.wikipedia.org/wiki/P%C3%B4quer, mas não precisa pois vamos lhe dizer o que se tem que saber para os exercícios.

Cartas

Há 52 cartas em um baralho, cada uma pertencendo a um dos quatro naipes e uma das treze cartas de valores diferentes. Os naipes são Espadas (), Copas (), Ouro () e Paus (). Os valores são Ás (A), 2, 3, 4, 5, 6, 7, 8, 9, 10, Valete (J), Rainha (Q) e Rei (K). Dependendo do jogo que você está jogando, um Ás pode ser maior que o rei ou menor que 2.

Se queremos definir um novo objeto para representar uma carta de baralho, é óbvio quais devem ser os atributos: o valor e o naipe. Mas não é tão óbvio que tipo de atributo deve ser. Uma possibilidade é usar strings contendo palavras como "Espadas" para os naipes e "Rainha" para os valores. Um problema com essa implementação é que não seria fácil comparar as cartas para ver quais teriam maior valor ou naipe.

Uma alternativa é usar números inteiros para codificar os valores e os naipes. Nesse contexto, “codificar” significa que vamos definir um mapeamento entre os números e naipes, ou entre os números e valores do baralho. Esse tipo de codificação não é para ser secreto (isso seria “criptografia”).

Por exemplo, esta tabela mostra os naipes e os números inteiros correspondentes:

  • \(\mapsto\) 4

  • \(\mapsto\) 3

  • \(\mapsto\) 2

  • \(\mapsto\) 1

Este código facilita a comparação das cartas, já que os naipes mais valorizados estão relacionados com números mais altos e podemos comparar os naipes comparando os seus números.

Estamos usando o símbolo \(\mapsto\) para deixar claro que essas correspondências não fazem parte do programa Julia. Elas fazem parte da formulação do programa, e não aparecem explicitamente no código.

A definição para a struct Carta é parecida com esta:

struct Carta
    naipe :: Int64
    valor :: Int64
    function Carta(naipe::Int64, valor::Int64)
        @assert(1 ≤ naipe ≤ 4, "naipe não está entre 1 e 4")
        @assert(1 ≤ valor ≤ 13, "valor não está entre 1 e 13")
        new(naipe, valor)
    end
end

Para criar uma Carta, você chama Carta com o naipe e o valor desejado:

julia> rainha_de_ouro = Carta(2, 12)
Carta(2, 12)

Variáveis Globais

Para imprimir objetos Carta de uma forma que as pessoas possam ler facilmente, precisamos de um mapeamento dos códigos inteiros até os valores e naipes correspondentes. Uma maneira natural de fazer isso é com as listas de strings:

const nomes_naipes = ["♣", "♦", "♥", "♠"]
const nomes_valores = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"]

As variáveis nomes_naipes e nomes_valores são variáveis globais. A declaração const significa que a variável só pode ser atribuída uma vez. Isso resolve o problema de desempenho das variáveis globais.

Agora podemos implementar um método show apropriado:

function Base.show(io::IO, carta::Carta)
    print(io, nomes_valores[carta.valor], nomes_naipes[carta.naipe])
end

A expressão nomes_valores[carta.valor] significa “use o campo valor do objeto carta como um índice na matriz nomes_valores e selecione a string apropriada.”

Com os métodos que temos até agora, podemos criar e imprimir as cartas:

julia> Carta(3, 11)
J♥

Comparando Cartas

Para os tipos embutidos, existem operadores relacionais (<, >, == etc.) que comparam os valores e determinam quando um é maior que, menor que ou igual ao outro. Para os tipos definidos pelo programador, podemos sobrepor o comportamento dos operadores embutidos ao estabelecer um método chamado <.

A ordenação correta das cartas não é óbvia. Por exemplo, qual é o melhor, o 3 de Paus ou o 2 de Ouros? Um tem um valor mais alto, mas o outro tem um naipe mais alto. A fim de comparar as cartas, você tem que decidir se o valor ou o naipe é mais importante.

A resposta pode depender de qual jogo você está jogando, mas para simplificar, faremos a escolha arbitrária de que naipe é mais importante, significando que todas cartas de Espadas são superiores as cartas de Diamantes e assim por diante.

Decidido isso, podemos escrever <

import Base.<

function <(c1::Carta, c2::Carta)
    (c1.naipe, c1.valor) < (c2.naipe, c2.valor)
end
Exercício 18-1

Escreva um método < para os objetos MeuHorário. Você pode usar a comparação de tupla, mas também pode considerar a comparação de inteiros.

Teste Unitário```

Testes unitários permitem verificar a corretude do seu código, comparando os resultados do seu código com o que você espera. Isso pode ser útil para garantir que seu código ainda esteja correto após as modificações, e também é uma maneira de predefinir o comportamento correto do seu código durante o desenvolvimento.

Os testes unitários simples podem ser realizados com as macros @test:

julia> using Test

julia> @test Carta(1, 4) < Carta(2, 4)
Test Passed
julia> @test Carta(1, 3) < Carta(1, 4)
Test Passed

@test retorna "Test Passed" se a expressão seguinte for true, "Test Failed" se for false, e "Error Result" se não puder ser avaliado.

Baralhos

Agora que temos Cartas, o próximo passo é a definição do Baralhos. Como um baralho é composto de cartas, é natural que cada Baralho contenha uma lista de cartas como um atributo.

A seguir, define-se uma struct para Baralho. O construtor cria os campos das cartas e gera o conjunto padrão das cinquenta e dois cartas:

struct Baralho
    cartas :: Array{Carta, 1}
end

function Baralho()
    baralho = Baralho(Carta[])
    for naipe in 1:4
        for valor in 1:13
            push!(baralho.cartas, Carta(naipe, valor))
        end
    end
    baralho
end

A maneira mais fácil de preencher o baralho é com um laço aninhado. O laço externo enumera os naipes de 1 a 4. O laço interno enumera os valores de 1 a 13. Cada iteração cria uma nova Carta com o naipe e o valor correntes e envia-a para baralho.cartas.

Aqui está um método show para Baralho:

function Base.show(io::IO, baralho::Baralho)
    for carta in baralho.cartas
        print(io, carta, " ")
    end
    println()
end

Veja como ficou o resultado:

julia> Baralho()
A♣ 2♣ 3♣ 4♣ 5♣ 6♣ 7♣ 8♣ 9♣ 10♣ J♣ Q♣ K♣ A♦ 2♦ 3♦ 4♦ 5♦ 6♦ 7♦ 8♦ 9♦ 10♦ J♦ Q♦ K♦ A♥ 2♥ 3♥ 4♥ 5♥ 6♥ 7♥ 8♥ 9♥ 10♥ J♥ Q♥ K♥ A♠ 2♠ 3♠ 4♠ 5♠ 6♠ 7♠ 8♠ 9♠ 10♠ J♠ Q♠ K♠

Adicionar, Remover, Embaralhar e Ordenar

Para distribuir as cartas, gostaríamos de uma função que remove uma carta do baralho e devolve-a. A função pop! fornece uma maneira apropriada de fazer isso:

function Base.pop!(baralho::Baralho)
    pop!(baralho.cartas)
end

Como pop! remove a última carta do baralho, estamos distribuindo a partir do fundo do baralho.

Para adicionar uma carta, podemos usar a função push!:

function Base.push!(baralho::Baralho, carta::Carta)
    push!(baralho.cartas, carta)
    baralho
end

Um método como esse, que usa outro método sem fazer muito trabalho, às vezes é chamado de folheado. A metáfora vem do trabalho com madeira, onde um folheado de madeira é uma fina camada de madeira de boa qualidade colada à superfície de um pedaço de madeira mais barato para melhorar a aparência.

Neste caso, push! é um método “fino” que expressa uma operação de lista relativamente apropriado para baralhos. Ele melhora a aparência, ou interface, da implementação.

Como um outro exemplo, podemos escrever um método chamado shuffle! usando a função Random.shuffle!:

using Random

function Random.shuffle!(baralho::Baralho)
    shuffle!(baralho.cartas)
    baralho
end
Exercício 18-2

Escreva uma função chamada sort! que usa a função sort! para ordenar as cartas em um Baralho. A função sort! usa o método isless na nossa definição para a ordenação.

Tipos Abstratos e Subtipagem

Queremos um tipo que represente uma “mão”, ou seja, as cartas que estão nas mãos de um jogador. Uma mão é semelhante a um baralho: ambas são compostas de uma coleção de cartas, e ambas precisam de operações como adicionar e remover cartas.

Uma mão também é diferente de um baralho; existem operações que queremos para as mãos de cartas que não faz sentido para um baralho. Por exemplo, no pôquer, podemos comparar duas mãos para ver qual delas vence. No bridge, podemos calcular uma pontuação para uma mão para fazer um lance.

Portanto, precisamos de uma maneira de agrupar os tipos concretos relacionados. No Julia, isso é feito ao definir um tipo abstrato que serve como o progenitor de Baralho e Mão. Isso é chamado subtipagem.

Vamos nomear esse tipo abstrato de Conjunto_Carta:

abstract type Conjunto_Carta end

Um novo tipo abstrato é criado com a palavra-chave abstract type. Um tipo “progenitor” opcional pode ser especificado colocando <: após o nome seguido do nome de um tipo abstrato já existente.

Quando nenhum supertipo é dado, o supertipo padrão é Any - um tipo abstrato predefinido do qual todos os objetos são instâncias e todos os tipos são subtipos.

Agora podemos expressar que Baralho é um descendente de Conjunto_Carta:

struct Baralho <: Conjunto_Carta
    cartas :: Array{Carta, 1}
end

function Baralho()
    baralho = Baralho(Carta[])
    for naipe in 1:4
        for valor in 1:13
            push!(baralho.cartas, Carta(naipe, valor))
        end
    end
    baralho
end

O operador isa verifica se um objeto é de um determinado tipo:

julia> baralho = Baralho();

julia> baralho isa Conjunto_Carta
true

Uma mão também é um tipo de Conjunto_Carta:

struct Mão <: Conjunto_Carta
    cartas :: Array{Carta, 1}
    identificação :: String
end

function Mão(identificação::String="")
    Mão(Carta[], identificação)
end

Em vez de encher a mão com 52 novas cartas, o construtor de Mão inicializa cartas com uma lista vazia. Um argumento opcional pode ser passado para o construtor, atribuindo uma identificação para a Mão.

julia> mão = Mão("nova mão")
Mão(Carta[], "nova mão")

Tipos Abstratos e Funções

Agora podemos expressar as operações comuns entre Baralho e Mão como funções tendo como argumento Conjunto_Carta:

function Base.show(io::IO, cc::Conjunto_Carta)
    for carta in cc.cartas
        print(io, carta, " ")
    end
end

function Base.pop!(cc::Conjunto_Carta)
    pop!(cc.cartas)
end

function Base.push!(cc::Conjunto_Carta, carta::Carta)
    push!(cc.cartas, carta)
    nothing
end

Podemos usar pop! e push! para dar uma carta:

julia> baralho = Baralho()
A♣ 2♣ 3♣ 4♣ 5♣ 6♣ 7♣ 8♣ 9♣ 10♣ J♣ Q♣ K♣ A♦ 2♦ 3♦ 4♦ 5♦ 6♦ 7♦ 8♦ 9♦ 10♦ J♦ Q♦ K♦ A♥ 2♥ 3♥ 4♥ 5♥ 6♥ 7♥ 8♥ 9♥ 10♥ J♥ Q♥ K♥ A♠ 2♠ 3♠ 4♠ 5♠ 6♠ 7♠ 8♠ 9♠ 10♠ J♠ Q♠ K♠
julia> shuffle!(baralho)
7♣ J♠ J♦ 5♦ 3♦ 8♠ 6♦ 4♥ 4♣ 2♠ J♣ 7♥ K♦ 5♠ 2♥ 5♥ Q♣ J♥ 10♣ 10♠ K♠ A♣ Q♠ Q♦ 3♥ 4♦ 10♥ 6♣ 8♥ 3♣ 2♣ A♦ 10♦ 7♦ 7♠ 4♠ K♥ A♠ 8♣ 5♣ 2♦ 9♣ 6♠ K♣ 9♦ Q♥ 6♥ 8♦ 9♠ 9♥ 3♠ A♥
julia> carta = pop!(baralho)
A♥
julia> push!(mão, carta)

Um próximo passo natural é encapsular esse código em uma função chamada move!:

function move!(cc1::Conjunto_Carta, cc2::Conjunto_Carta, n::Int)
    @assert 1 ≤ n ≤ length(cc1.cartas)
    for i in 1:n
        carta = pop!(cc1)
        push!(cc2, carta)
    end
    nothing
end

A função move! recebe três argumentos, dois objetos Conjunto_Carta e o número de cartas a serem distribuídas. Ela modifica os objetos Conjunto_Carta e retorna nothing.

Em alguns jogos, as cartas são transferidas de uma mão para outra ou de uma mão para o baralho. Você pode usar move! para qualquer uma dessas operações já que cc1 e cc2 podem ser ou um Baralho ou uma Mão.

Diagramas de Tipos

Até agora temos visto os diagramas de pilha, que mostram o estado de um programa, e os diagramas de objeto, que mostram os atributos de um objeto e os seus valores. Estes diagramas são como um retrato na execução de um programa, portanto eles mudam conforme o programa é executado.

Eles também são muito detalhados e dependendo de alguns propósitos, demasiadamente detalhados. Um diagrama de tipo é uma representação mais abstrata da estrutura de um programa. Ao invés de mostrar objetos individuais, ele mostra os tipos e as relações entre eles.

Existem vários tipos de relacionamento entre os tipos:

  • Objetos de um tipo concreto podem conter referências a objetos de outro tipo. Por exemplo, cada Retângulo contém uma referência a um Ponto, e cada Baralho contém referências a uma lista de Cartas. Este tipo de relacionamento é chamado de TEM-UM, como em “um Retângulo tem um Ponto”.

  • Um tipo concreto pode ter um tipo abstrato como um supertipo. Esse relacionamento é chamado de É-UM, como em “uma Mão é uma espécie de Conjunto_Carta”.

  • Um tipo pode depender do outro no sentido de que os objetos de um tipo recebem os objetos do segundo tipo como parâmetros ou usam os objetos do segundo tipo como parte de um cálculo. Esse tipo de relacionamento é denominado dependência.

fig181
Figura 23. Diagrama de Tipo

A flecha com uma ponta de triângulo oco representa um relacionamento É-UM; e neste caso, indica que a Mão tem como supertipo o Conjunto_Carta.

A ponta da seta padrão representa um relacionamento TEM-UM e neste caso, um Baralho tem referências aos objetos de Carta.

A estrela (*) perto da ponta da seta é uma multiplicidade que indica a quantidade de Cartas que um Baralho tem. Uma multiplicidade pode ser um número simples, como 52, um intervalo, como 5:7 ou uma estrela, que indica que um Baralho pode ter qualquer número de Cartas.

Não há dependências neste diagrama. Elas usualmente seriam mostradas com uma seta tracejada. E caso haja muitas dependências, elas são às vezes omitidas.

Um diagrama mais detalhado pode mostrar que um Baralho realmente contém uma lista de Cartas, mas tipos internos como uma lista e dicionários geralmente não são inclusos nos diagramas de tipo.

Depuração

A subtipagem pode dificultar a depuração, porque quando você chama uma função com um objeto como argumento, pode ser difícil descobrir qual método será chamado.

Suponha que você está escrevendo uma função que funciona com os objetos Mão. Você gostaria que ela funcionasse com todos os tipos de Mão+s, como +Mãos de Pôquer, Mãos de Bridge, etc. Se você chamar um método como sort!, pode ter chamado o que foi definido para um tipo abstrato Mão, mas se um método sort! com qualquer um dos subtipos como argumento existir, você terá essa versão em seu lugar. Este comportamento é normalmente uma coisa boa, mas pode ser confuso.

function Base.sort!(mão::Mão)
    sort!(mão.cartas)
end

Sempre que você não tiver certeza sobre o fluxo de execução do seu programa, a solução mais simples é adicionar comandos de impressão no início dos métodos relevantes. Se shuffle! imprime uma mensagem que diz algo como Executando shuffle! Baralho, à medida que o programa é executado, rastreia-se o fluxo de execução.

Como uma alternativa melhor, você também pode usar a macro @which:

julia> @which sort!(mão)
sort!(mão::Mão) in Main at REPL[5]:1

Portanto, o método sort! para mão recebe como argumento um objeto do tipo Mão.

Eis uma sugestão de design: quando você substitui um método, a interface do novo método deve ser a mesma que a do antigo. Ele deve receber os mesmos parâmetros, retornar o mesmo tipo e obedecer às mesmas precondições e pós-condições. Se você seguir esta regra, verá que qualquer função elaborada para trabalhar com uma instância de um supertipo, como um Conjunto_Carta, também funcionará com instâncias de seus subtipos Baralho e Mão.

Se você violar essa regra, chamada de “princípio de substituição de Liskov”, seu código tombará como (desculpa) um castelo de cartas.

A função supertype pode ser usada para encontrar o supertipo direto de um tipo.

julia> supertype(Baralho)
Conjunto_Carta

Encapsulamento de Dados

Os capítulos anteriores apresentam um plano de desenvolvimento que podemos chamar de “design orientado a tipos”. Identificamos os objetos de que precisamos—como Ponto, Retângulo e MeuHorário— e definimos estruturas para representá-los. Em cada caso, há uma correspondência óbvia entre o objeto e alguma entidade no mundo real (ou pelo menos em um mundo matemático).

Mas, às vezes, é menos óbvio de quais objetos você precisa e como eles devem interagir. Nesse caso, precisa-se de um plano de desenvolvimento diferente. Da mesma maneira que descobrimos funções de interface por encapsulamento e generalização, podemos descobrir os tipos de interface por encapsulamento de dados.

A análise de Markov, de [markov_analysis], fornece um bom exemplo. Se você baixar meu código em https://github.com/JuliaIntro/JuliaIntroBR.jl/blob/master/src/solutions/chap13.jl, verá que ele usa duas variáveis globais—suffixos and prefixo—que são lidos e escritos a partir de várias funções.

sufixos = Dict()
prefixo = []

Como estas variáveis são globais, podemos fazer só uma análise por vez. Se lermos dois textos, os seus prefixos e sufixos seriam adicionados às mesmas estruturas de dados (o que faz com que algum texto gerado seja interessante).

Para rodar várias análises e mantê-las separadas, podemos encapsular o estado de cada análise em um objeto. Eis o que isso parece:

struct Markov
    ordem :: Int64
    sufixos :: Dict{Tuple{String,Vararg{String}}, Array{String, 1}}
    prefixos :: Array{String, 1}
end

function Markov(ordem::Int64=2)
    new(ordem, Dict{Tuple{String,Vararg{String}}, Array{String, 1}}(), Array{String, 1}())
end

Em seguida, transformamos as funções em métodos. Por exemplo, aqui está processa_palavra:

function processa_palavra(markov::Markov, palavra::String)
    if length(markov.prefixo) < markov.ordem
        push!(markov.prefixo, palavra)
        return
    end
    get!(markov.sufixos, (markov.prefixo...,), Array{String, 1}())
    push!(markov.sufixos[(markov.prefixo...,)], palavra)
    popfirst!(markov.prefixo)
    push!(markov.prefixo, palavra)
end

Transformar um programa como esse—alterando o design sem alterar o comportamento—é outro exemplo de refatoração (veja [refactoring]).

Este exemplo sugere um plano de desenvolvimento para elaborar os tipos:

  • Comece por escrever funções que leem e escrevem variáveis globais (quando necessário).

  • Uma vez que o programa esteja funcionando, procure por associações entre variáveis globais e as funções que as utilizam.

  • Encapsule as variáveis relacionadas como os campos de uma estrutura.

  • Transforme as funções associadas em métodos que recebem objetos do novo tipo como argumento.

Exercício 18-3

Faça o download do meu código Markov em https://github.com/JuliaIntro/JuliaIntroBR.jl/blob/master/src/solutions/chap13.jl e siga os passos descritos acima para encapsular as variáveis globais como atributos de uma nova estrutura denominada Markov.

Glossário

codificação

Representação de um conjunto de valores usando outro conjunto de valores através da construção de um mapeamento entre eles.

teste unitário

Procedimento padronizado para testar o corretismo do código.

folheado

Um método ou uma função que disponibiliza uma interface diferente para outra função sem fazer muito cálculo.

subtipagem

A capacidade de definir uma hierarquia de tipos relacionados.

tipo abstrato

Um tipo que pode atuar como progenitor de outro tipo.

tipo concreto

Um tipo que pode ser construído.

subtipo

Um tipo que tem como progenitor um tipo abstrato.

supertipo

Um tipo abstrato que é o progenitor de outro tipo.

Relacionamento É-UM

Um relacionamento entre um subtipo e seu supertipo.

Relacionamento TEM-UM

Um relacionamento entre dois tipos em que as instâncias de um tipo contêm referências às instâncias do outro.

dependência

Um relacionamento entre dois tipos em que instâncias de um tipo usam as instâncias do outro tipo, sem armazená-las como campos.

diagrama de tipo

Um diagrama que mostra os tipos de um programa e as relações entre eles.

multiplicidade

Uma notação em um diagrama de tipos que mostra, em um relacionamento TEM-UM, a quantidade de referências para as instâncias de outra classe.

encapsulamento de dados

Um plano de desenvolvimento de programa, que consiste em um protótipo que usa variáveis ​​globais e uma versão final que transforma as variáveis ​​globais em campos de uma instância.

Exercícios

Exercício 18-4

Para o programa seguinte, desenhe um diagrama de tipos que mostre os seus tipos e as relações entre eles.

abstract type PingPongProgenitor end

struct Ping <: PingPongProgenitor
    pong :: PingPongProgenitor
end

struct Pong <: PingPongProgenitor
    pings :: Array{Ping, 1}
    function Pong(pings=Array{Ping, 1}())
        new(pings)
    end
end

function addping(pong::Pong, ping::Ping)
    push!(pong.pings, ping)
    nothing
end

pong = Pong()
ping = Ping(pong)
addping(pong, ping)
Exercício 18-5

Escreva um método chamado dar_carta! que recebe três parâmetros, o Baralho, o número de mãos e o número de cartas por mão. Ele deve criar o número apropriado de objetos Mão, distribuir o número compatível de cartas por mão e retornar uma lista de +Mão+s.

Exercício 18-6

Seguem as jogadas possíveis no pôquer, em ordem crescente de valor e ordem decrescente de probabilidade:

par

duas cartas com o mesmo valor

dois pares

dois pares de cartas com o mesmo valor

trinca de um tipo

três cartas com o mesmo valor

sequência

cinco cartas com valores em sequência (os Ases podem ser altos ou baixos, então Ás-2-3-4-5 é uma sequência bem como 10-Valete-Rainha-Rei-Ás, mas Rainha-Rei-Ás-2-3 não é.)

flush

cinco cartas com o mesmo naipe

full house

três cartas com um valor, duas cartas com outro

quadra de um tipo

quatro cartas com o mesmo valor

sequência de mesmo naipe

cinco cartas em sequência (conforme definido acima) e com o mesmo naipe

O objetivo deste exercício é estimar as probabilidades de se tirar estas várias jogadas.

  1. Adicione métodos chamados tem_par, tem_2pares, etc. que retornam true ou false se a mão cumpre ou não as regras relevantes. Seu código deve funcionar corretamente para as “mãos” que contém qualquer número de cartas (embora 5 e 7 sejam a quantidade mais comuns).

  2. Escreva um método chamado classificar que calcula a classificação do valor mais alto para uma mão e identifica adequadamente o campo jogada. Por exemplo, um mão de 7 cartas pode conter um flush e um par e ele deve ser identificado como “flush”.

  3. Quando você estiver convencido de que seus métodos de classificação estão funcionando, o próximo passo é estimar as probabilidades das várias jogadas. Escreva uma função que embaralha um baralho de cartas, divide-o em mãos, classifica as mãos e conta o número de vezes que as várias classificações aparecem.

  4. Imprima uma tabela de classificações e suas probabilidades. Execute seu programa com uma quantidade de mãos cada vez maiores até que os valores de saída convirjam para um grau razoável de precisão. Compare os seus resultados com os valores em https://pt.wikipedia.org/wiki/Lista_de_jogadas_do_p%C3%B4quer.

19. Extras: Sintaxe

Uma das nossas metas para este livro é ensinar o mínimo possível de Julia. Quando havia duas formas de fazer algo, escolhíamos uma e evitávamos mencionar a outra. Ou às vezes, a segunda forma era proposta como exercício.

Agora queremos retornar a algumas coisas boas que ficaram pra trás. O Julia oferece uma variedade de recursos que não são realmente necessários—você pode escrever um bom código sem eles—mas com eles pode-se escrever um código mais conciso, legível ou eficiente e, às vezes, todos os três.

Neste e no próximo capítulo, iremos discutir coisas dos capítulos anteriores que deixamos para trás:

  • os suplementos de sintaxe

  • as funções, os tipos e as macros diretamente disponíveis em Base

  • as funções, os tipos e as macros da Biblioteca Padrão

Tuplas Nomeadas

Você pode nomear os componentes de uma tupla, criando uma tupla nomeada.

julia> x = (a=1, b=1+1)
(a = 1, b = 2)
julia> x.a
1

Nas tuplas nomeadas, os campos podem ser acessados pelo nome usando a sintaxe do ponto (x.a).

Funções

Uma função no Julia pode ser definida por uma sintaxe compacta:

julia> f(x,y) = x + y
f (generic function with 1 method)

Funções Anônimas

Podemos definir uma função sem especificar um nome:

julia> x -> x^2 + 2x - 1
#1 (generic function with 1 method)
julia> function (x)
           x^2 + 2x - 1
       end
#3 (generic function with 1 method)

Estes são os exemplos de funções anônimas. E elas geralmente são usadas como argumentos para outras funções:

julia> using Plots

julia> plot(x -> x^2 + 2x - 1, 0, 10, xlabel="x", ylabel="y")

Plot mostra a saída do comando plot.

fig191
Figura 24. Plot

Argumentos de Palavras-Chave

Argumentos de funções também podem ser nomeadas:

julia> function meu_plot(x, y; style="solid", width=1, color="black")
           ###
       end
meu_plot (generic function with 1 method)
julia> meu_plot(0:10, 0:10, style="dotted", color="blue")

Argumentos de palavras-chave em uma função são especificados depois de um ponto e vírgula na assinatura, apesar de poder ser chamado com uma vírgula.

Fechamentos

Um fechamento é uma técnica que permite uma função capturar uma variável definida fora do escopo da chamada da função.

julia> foo(x) = ()->x
foo (generic function with 1 method)

julia> bar = foo(1)
#1 (generic function with 1 method)

julia> bar()
1

Nesse exemplo, a função foo retorna uma função anônima que possui acesso ao argumento x da função foo. A variável bar aponta para a função anônima e retorna o valor do argumento de foo.

Blocos

Um bloco é uma forma de agrupar diversos comandos. Um bloco começa com a palavra-chave begin e termina com end.

No Estudo de Caso: Design de Interface, a macro @svg foi introduzida:

🐢 = Turtle()
@svg begin
    forward(🐢, 100)
    turn(🐢, -90)
    forward(🐢, 100)
end

Neste exemplo, a macro @svg possui um argumento único, i.e. um bloco que está agrupando 3 chamadas de função.

Blocos let

Um bloco let é útil para criar novas ligações, i.e. locais que podem se referir a valores.

julia> x, y, z = -1, -1, -1;

julia> let x = 1, z
           @show x y z;
       end
x = 1
y = -1
ERROR: UndefVarError: z not defined
julia> @show x y z;
x = -1
y = -1
z = -1

Nesse exemplo, a primeira macro @show mostra o x local, a global y e a local indefinida z. As variáveis globais não são tocadas.

Blocos do

Em Lendo e Escrevendo, tivemos que fechar o arquivo após o término da escrita. Isso pode ser feito automaticamente usando um bloco do:

julia> dado = "O meu nome é Severino, \nnão tenho outro de pia.\n"
"O meu nome é Severino, \nnão tenho outro de pia.\n"
julia> open("output.txt", "w") do fout
           write(fout, dado)
       end
50

Nesse exemplo, fout é um fluxo de arquivo usado para a saída.

Essa funcionalidade é equivalente a

julia> f = fout -> begin
           write(fout, dado)
       end
#3 (generic function with 1 method)
julia> open(f, "output.txt", "w")
50

A função anônima é usada como o primeiro argumento da função open:

function open(f::Function, args...)
    io = open(args...)
    try
        f(io)
    finally
        close(io)
    end
end

Um bloco do pode “capturar” as variáveis do escopo que o circunda. Por exemplo, a variável dado em open ... do no exemplo acima é capturada de escopo externo.

Controle de Fluxo

Operador Ternário

O operador ternário ?: é uma alternativa para uma declaração if-else usada quando você precisa fazer uma escolha entre valores de expressão única.

julia> a = 150
150
julia> a % 2 == 0 ? println("par") : println("ímpar")
par

A expressão antes de ? é uma condição. Se a condição for verdadeira (true), a expressão antes de : é avaliada, caso contrário, a expressão depois de : é avaliada.

Avaliação de Curto-Circuíto

Os operadores && e || fazem uma avaliação de curto-circuito: o próximo argumento só é avaliado quando é necessário determinar o valor final.

Por exemplo, uma rotina recursiva do fatorial pode ser definida da seguinte maneira:

function fat(n::Integer)
    n >= 0 || error("n deve ser inteiro não negativo")
    n == 0 && return 1
    n * fat(n-1)
end

Tarefas (também conhecidas como Corrotinas)

Uma tarefa é uma estrutura de controle que pode passar o controle cooperativamente sem retornar. Em Julia, uma tarefa pode ser implementada como uma função tendo como o primeiro argumento um objeto de canal (Channel). Um canal é usado para passar valores de uma função para quem a chamou.

A sequência de Fibonnaci pode ser gerada por uma tarefa.

function fib(c::Channel)
    a = 0
    b = 1
    put!(c, a)
    while true
        put!(c, b)
        (a, b) = (b, a+b)
    end
end

put armazena os valores em um objeto de canal e take! lê os valores dele:

julia> fib_gen = Channel(fib);

julia> take!(fib_gen)
0
julia> take!(fib_gen)
1
julia> take!(fib_gen)
1
julia> take!(fib_gen)
2
julia> take!(fib_gen)
3

O construtor Channel cria a tarefa. A função fib é suspendida aṕos cada chamada para put! e retorna depois de take!. Por questões de performance, diversos valores da sequência são armazenados temporariamente no objeto de canal durante um ciclo de retomada/suspensão.

Um objeto de canal também pode ser usado como um iterador:

julia> for val in Channel(fib)
           print(val, " ")
           val > 20 && break
       end
0 1 1 2 3 5 8 13 21

Tipos

Tipos Primitivos

Um tipo concreto que consiste simplesmente de bits é chamado de tipo primitivo. Ao contrário da maioria das linguagens, no Julia você pode declarar os seus próprios tipos primitivos. Os tipos primitivos padrões são definidos da mesma maneira:

primitive type Float64 <: AbstractFloat 64 end
primitive type Bool <: Integer 8 end
primitive type Char <: AbstractChar 32 end
primitive type Int64 <: Signed 64 end

O número nos comandos especificam quantos bits são necessários.

O exemplo a seguir cria um tipo primitivo Byte e um construtor:

julia> primitive type Byte 8 end

julia> Byte(val::UInt8) = reinterpret(Byte, val)
Byte
julia> b = Byte(0x01)
Byte(0x01)

A função reinterpret é usada para armazenar os bits de um inteiro não assinado com 8 bits (UInt8) no byte.

Tipos Paramétricos

O tipo de sistema do Julia é paramétrico, significando que os tipos podem possuir parâmetros.

Parâmetros de tipo são introduzidos depois do nome do tipo, cercado por chaves:

struct Ponto{T<:Real}
    x::T
    y::T
end

Isso define um novo tipo paramétrico, Ponto{T<:Real}, segurando duas "coordenadas" do tipo T, da qual pode ser de qualquer tipo desde que tenha Real como supertipo.

julia> Ponto(0.0, 0.0)
Ponto{Float64}(0.0, 0.0)

Além dos tipos compostos, tipos abstratos e tipos primitivos também podem ter um parâmetro de tipo.

Dica

Ter tipos concretos nos campos da struct é absolutamente recomendado por motivos de desempenho, portanto essa é uma boa maneira de tornar Ponto rápido e flexível.

União de Tipo

Uma união de tipo é um tipo paramétrico abstrato que pode atuar como qualquer um dos tipos do seu argumento:

julia> IntOuString = Union{Int64, String}
Union{Int64, String}
julia> 150 :: IntOuString
150
julia> "Julia" :: IntOuString
"Julia"

Uma união de tipo é na maioria das linguagens de computador uma construção interna para pensar sobre os tipos. O Julia, no entanto, expõe esse recurso aos seus usuários porque um código eficiente pode ser gerado quando a união de tipo possui um pequeno número de tipos. Esse recurso oferece ao programador do Julia uma tremenda flexibilidade para controlar o despacho.

Métodos

Métodos Paramétricos

As definições de método também podem ter parâmetros de tipo que qualificam sua assinatura:

julia> é_ponto_int(p::Ponto{T}) where {T} = (T === Int64)
é_ponto_int (generic function with 1 method)
julia> p = Ponto(1, 2)
Ponto{Int64}(1, 2)
julia> é_ponto_int(p)
true

Objetos Semelhantes a Funções

Qualquer objeto arbitrário de Julia pode ser “chamado”. Tais objetos “chamáveis” às vezes são denominados functores.

struct Polinômio{R}
    coef::Vector{R}
end

function (p::Polinômio)(x)
    val = p.coef[end]
    for coef in p.coef[end-1:-1:1]
        val = val * x + coef
    end
    val
end

Para calcular o polinômio, basta chamá-lo:

julia> p = Polinômio([1,10,100])
Polinômio{Int64}([1, 10, 100])
julia> p(3)
931

Construtores

Tipos paramétricos podem ser construídos explicitamente ou implicitamente:

julia> Ponto(1,2)         # T implícito
Ponto{Int64}(1, 2)
julia> Ponto{Int64}(1, 2) # T explícito
Ponto{Int64}(1, 2)
julia> Ponto(1,2.5)       # T implícito
ERROR: MethodError: no method matching Ponto(::Int64, ::Float64)

Construtores internos e externos padrões são gerados para cada T:

struct Ponto{T<:Real}
    x::T
    y::T
    Ponto{T}(x,y) where {T<:Real} = new(x,y)
end

Ponto(x::T, y::T) where {T<:Real} = Ponto{T}(x,y);

e ambos x e y devem ser do mesmo tipo.

Quando x e y possuem tipos diferentes, o construtor externo a seguir pode ser definido:

Ponto(x::Real, y::Real) = Ponto(promote(x,y)...);

A função promote é detalhada em Promoção.

Conversão e Promoção

O Julia tem um sistema para promover argumentos para um tipo comum. Isso não é feito automaticamente, mas pode ser facilmente realizado.

Conversão

Um valor pode ser convertido de um tipo para o outro:

julia> x = 12
12
julia> typeof(x)
Int64
julia> convert(UInt8, x)
0x0c
julia> typeof(ans)
UInt8

Podemos adicionar os nossos próprios métodos convert:

julia> Base.convert(::Type{Ponto{T}}, x::Array{T, 1}) where {T<:Real} = Ponto(x...)

julia> convert(Ponto{Int64}, [1, 2])
Ponto{Int64}(1, 2)

Promoção

Promoção é a conversão de valores dos tipos mistos para um único tipo comum:

julia> promote(1, 2.5, 3)
(1.0, 2.5, 3.0)

Em geral, os métodos para a função promote não são diretamente definidos, mas a função auxiliar promote_rule é usada para especificar as regras da promoção:

promote_rule(::Type{Float64}, ::Type{Int32}) = Float64

Metaprogramação

O código Julia pode ser representado como uma estrutura de dados da própria linguagem. Isso permite que um programa transforme e gere o seu próprio código.

Expressões

Todo programa do Julia começa como uma string:

julia> prog = "1 + 2"
"1 + 2"

A próxima etapa é traduzir cada string em um objeto chamado expressão, representado pelo tipo Expr do Julia:

julia> ex = Meta.parse(prog)
:(1 + 2)
julia> typeof(ex)
Expr
julia> dump(ex)
Expr
  head: Symbol call
  args: Array{Any}((3,))
    1: Symbol +
    2: Int64 1
    3: Int64 2

A função dump detalha os objetos expr.

Expressões podem ser diretamente construídas prefixando : entre parênteses ou usando uma citação em bloco

julia> ex = quote
           1 + 2
       end;

eval

O Julia pode avaliar um objeto de expressão usando eval:

julia> eval(ex)
3

Cada módulo possui sua própria função eval que avalia as expressões em seu escopo.

Atenção

Quando você está usando muitas chamadas para a função eval, geralmente isso significa que algo está errado. A função eval é considerada do “mal”.

Macros

Macros podem incluir o código gerado em um programa. Uma macro mapeia uma tupla de objetos Expr diretamente para uma expressão compilada:

Aqui está uma macro simples:

macro recipiente_variável(recipiente, elemento)
    return esc(:($(Symbol(recipiente,elemento)) = $recipiente[$elemento]))
end

As macros são chamadas ao colocar o prefixo @ (sinal de arroba) em seus nomes. A chamada de macro @recipiente_variável letras 1 é substituída por:

:(letras1 = letras[1])

@macroexpand @recipiente_variável letras 1 retorna essa expressão que é extremamente útil para a depuração.

Este exemplo ilustra como uma macro pode acessar o nome dos seus argumentos, algo que uma função não pode fazer. O comando return precisa ser “circundado” com esc, pois precisa ser resolvido no ambiente da chamada da macro.

Nota

Por que macros?

As macros geram e incluem fragmentos de código personalizado durante o tempo de interpretação, portanto, antes da execução do programa completo.

Funções Geradas

A macro @generated cria código especializado para os métodos, dependendo dos tipos dos argumentos:

@generated function quadrado(x)
    println(x)
    :(x * x)
end

O corpo retorna uma expressão entre aspas como uma macro.

Para quem chama, a função gerada se comporta como uma função regular:

julia> x = quadrado(2); # nota: a saída é da declaração println() no corpo
Int64
julia> x                # agora imprimimos x
4
julia> y = quadrado("spam");
String
julia> y
"spamspam"

Valores Ausentes

Valores ausentes podem ser representados através do objeto missing, que é a instância singleton (única) do tipo Missing.

As listas podem conter valores ausentes:

julia> a = [1, missing]
2-element Array{Union{Missing, Int64},1}:
 1
  missing

O tipo de elemento dessa lista é Union{Missing, T}, sendo T o tipo de valores não ausentes.

As funções de redução retornam missing quando chamadas nas listas que contêm valores ausentes

julia> sum(a)
missing

Nessa situação, use a função skipmissing para ignorar os valores ausentes:

julia> sum(skipmissing([1, missing]))
1

Chamando Códigos em C e Fortran

Muitos códigos estão escritos em C ou Fortran. Reutilizar o código testado geralmente é melhor do que escrever sua própria versão de um algoritmo. Julia pode chamar diretamente as bibliotecas existentes em C ou Fortran usando a sintaxe ccall.

Em [databases], introduzimos uma interface Julia para a biblioteca GDBM de funções de banco de dados. A biblioteca está escrita em C. Para fechar o banco de dados, uma chamada de função para close(db) teve que ser feita:

Base.close(dbm::DBM) = gdbm_close(dbm.handle)

function gdbm_close(handle::Ptr{Cvoid})
    ccall((:gdbm_close, "libgdbm"), Cvoid, (Ptr{Cvoid},), handle)
end

Um objeto dbm possui um campo handle do tipo Ptr{Cvoid}. Este campo guarda um ponteiro C que se refere ao banco de dados. Para fechar o banco de dados, a função C gdbm_close deve ser chamada tendo como único argumento o ponteiro C apontando para o banco de dados e sem valor de retorno. O Julia faz isso diretamente com a função ccall tendo como argumentos:

  • uma tupla que consiste em um símbolo que contém o nome da função que queremos chamar: :gdbm_close e a biblioteca compartilhada especificada como uma string: "libgdm",

  • o tipo de retorno: Cvoid,

  • uma tupla com os tipos de argumentos: (Ptr{Cvoid},) e

  • os valores do argumento: handle.

O mapeamento completo da biblioteca GDBM pode ser encontrado como um exemplo no repositório do JuliaIntroBR.

Glossário

fechamento

Função que captura as variáveis do seu escopo definido.

bloco let

Bloco que aloca novas ligações de variáveis.

função anônima

Função definida sem ter um nome.

tupla nomeada

Tupla com componentes nomeados.

argumentos de palavra-chave

Argumentos identificados pelo nome e não só pela posição.

bloco do

Construção de sintaxe usada para definir e chamar uma função anônima que se parece com um bloco de código normal.

operador ternário

Operador de fluxo de controle que usa três operandos para especificar uma condição, uma expressão a ser executada quando a condição produz true e uma expressão a ser executada quando a condição produz false.

avaliação de curto-circuíto

Avaliação de um operador booleano na qual o segundo argumento é executado ou avaliado apenas se o primeiro argumento não for suficiente para determinar o valor da expressão.

tarefa (também conhecida como corrotina)

Recurso de fluxo de controle que permite a suspensão e a retomada dos cálculos de maneira flexível.

tipo primitivo

Tipo concreto cujos dados consistem simplesmente de bits.

união de tipo

Tipo que inclui como objetos todas as instâncias de qualquer um dos seus parâmetros de tipo.

tipo paramétrico

Tipo que é parametrizado.

functor

Objeto com um método associado, para que ele possa ser chamado.

conversão

A conversão permite converter um valor de um tipo para outro.

promoção

Conversão de valores de tipos mistos em um único tipo comum.

expressão

Tipo do Julia que contém uma construção de linguagem.

macro

Forma de incluir o código gerado no corpo final de um programa.

funções geradas

Funções capazes de gerar código especializado, dependendo dos tipos dos argumentos.

valores ausentes

Instâncias que representam informações sem valor.

20. Extras: Base e a Biblioteca Padrão

O Julia vem com baterias inclusas. O módulo Base contém as funções, tipos e macros mais utéis. Eles estão diretamente disponíveis no Julia.

O Julia também fornece um grande número de módulos especializados em sua Biblioteca Padrão (Datas, Computação Distribuida, Álgebra Linear, Profiling, Números Aleatórios, …​). Funções, tipos e macros definidas na Biblioteca Padrão precisam ser importados antes de poderem ser usados:

  • import Módulo importa o módulo, e Módulo.fn(x) chama a função fn

  • using Módulo importa todas as funções, tipos e macros exportadas de Módulo.

Funcionalidades extras podem ser adicionadas a partir de uma crescente coleção de pacotes (https://juliaobserver.com).

Este capítulo não é uma substituição à documentação oficial do Julia. Apenas damos alguns exemplos para ilustrar o que é possível sem tornar o processo exaustivo. Funções já introduzidas em algum outro lugar não estão inclusas. Uma visão geral pode ser encontrada em https://docs.julialang.org.

Mensurando Performance

Nós vimos que alguns algoritmos executam melhor que outros. fibonnaci em Memos é muito mais rápido que fib em Mais Um Exemplo. A macro @time permite quantificar a diferença:

julia> fib(1)
1
julia> fibonacci(1)
1
julia> @time fib(40)
  0.567546 seconds (5 allocations: 176 bytes)
102334155
julia> @time fibonacci(40)
  0.000012 seconds (8 allocations: 1.547 KiB)
102334155

@time exibe o tempo que a função levou para executar, o número de alocações (allocations) e a memória alocada antes de retornar o resultado. A versão memoizada é efetivamente muito mais rápida, mas requer mais memória.

Não existem almoços grátis!

Dica

Uma função no Julia é compilada na primeira vez que é executada. Então para comparar dois algoritmos, eles devem ser implementados como funções para serem compilados e a primeira vez que são chamadas precisa ser excluída da medida de performance, caso contrário o tempo de compilação também é medido.

O pacote BenchmarkTools (https://github.com/JuliaCI/BenchmarkTools.jl) fornece a macro @btime que faz benchmarking da maneira certa. Então use-a!

Coleções e Estruturas de Dados

Em Subtração de Dicionário nós usamos dicionários para achar palavras que aparecem em um documento mas não em uma lista de palavra. A função que escrevemos recebe d1, que contém as palavras do documento como chaves, e d2, que contém a lista de palavras. Ela retorna um dicionário que contém chaves de d1 que não estão em d2.

function subtrai(d1, d2)
    res = Dict()
    for chave in keys(d1)
        if chave ∉ keys(d2)
            res[chave] = nothing
        end
    end
    res
end

Em todos estes dicionários, os valores são nothing porque nós nunca usamos eles. Como resultado, nós desperdiçamos um pouco de espaço de armazenamento.

O Julia fornece outro tipo embutido, chamado de conjunto (Set), que se comporta como uma coleção de chaves de dicionário mas sem valores. Adicionar elementos à um conjunto é rápido; assim como verificar que um elemento pertence à ele. Além disso, conjuntos fornecem funções e operadores para computar operações comuns de conjunto.

Por exemplo, a subtração de conjuntos está disponível em uma função chamada setdiff. Então podemos reescrever subtrai assim:

function subtrai(d1, d2)
    setdiff(d1, d2)
end

O resultado é um conjunto ao invés de um dicionário.

Alguns dos exercicíos deste livro podem ser feitos de forma concisa e eficientemente utilizando conjuntos. Por exemplo, aqui está uma solução para tem_duplicatas, de Exercise 10-7, que usa um dicionário:

function tem_duplicatas(t)
    d = Dict()
    for x in t
        if x ∈ d
            return true
        end
        d[x] = nothing
    end
    false
end

Quando um elemento aparece pela primeira vez, ele é adicionado ao dicionário. Se o mesmo elemento aparece novamente, a função retorna true.

Utilizando conjuntos, nós podemos escrever a mesma função assim:

function tem_duplicatas(t)
    length(Set(t)) < length(t)
end

Um elemento pode aparecer somente uma vez no conjunto, então se um elemento em t aparece mais de uma vez, o conjunto será menor que t. Se não há duplicatas, o conjunto terá o mesmo tamanho que t.

Nós também podemos usar conjuntos para fazer alguns exercícios de Estudo de Caso: Jogo de Palavras. Por exemplo, aqui está uma versão de usa_somente com um laço:

function usa_somente(palavra, disponível)
    for letra in palavra
        if letra ∉ disponível
            return false
        end
    end
    true
end

usa_somente verifica se todas as letras em palavra estão em disponível. Nós podemos reescreve-la da seguinte forma:

function usa_somente(palavra, disponível)
    Set(palavra) ⊆ Set(disponível)
end

O operador (\subseteq TAB) verifica se um conjunto é um subconjunto ou algum outro conjunto, incluindo a possibilidade deles serem iguais, que ocorre se todas as letras em palavra aparecem em disponível.

Exercício 20-1

Reescreva evita utilizando conjuntos.

Matemática

Números complexos também são suportados pelo Julia. A constante global im é vinculada ao número complexo \(\mathrm{i}\), representando a raiz quadrada de \(-1\).

Nós podemos agora verificar a identidade de Euler.

julia> ℯ^(im*π)+1
0.0 + 1.2246467991473532e-16im

O símbolo (\euler TAB) é a base dos logaritmos naturais.

Vamos ilustrar a natureza complexa de funções trigonométricas:

\[\begin{equation} {\cos\left(x\right)=\frac{\mathrm{e}^{\mathrm{i}x}+\mathrm{e}^{-\mathrm{i}x}}{2}\,.} \end{equation}\]

Nós podemos agora testar esta fórmula para valores diferentes de \(x\).

julia> x = 0:0.1:2π
0.0:0.1:6.2
julia> cos.(x) == 0.5*(ℯ.^(im*x)+ℯ.^(-im*x))
true

Aqui, outro exemplo do uso do operador ponto é mostrado. O Julia também permite que literais numéricos sejam justapostos com identificadores e coeficientes, como em .

Strings

Em Strings e Estudo de Caso: Jogo de Palavras, nós fizemos algumas buscas elementares com objetos string. O Julia pode no entanto, lidar com expressões regulares (regexes) compatíveis com o Perl, que facilita a tarefa de achar padrões complexos em objetos string.

A função usa_somente pode ser implementada como uma expressão regular:

function usa_somente(palavra, disponível)
  r = Regex("[^$(disponível)]")
  !occursin(r, palavra)
end

A expressão regular procura um caractere que não está na string disponível e occursin retorna true se o padrão é achado em palavra.

julia> usa_somente("banana", "abn")
true
julia> usa_somente("bananas", "abn")
false

Expressões regulares também podem ser construídas como literais strings não padronizadas prefixadas com r:

julia> match(r"[^abn]", "banana")

julia> m = match(r"[^abn]", "bananas")
RegexMatch("s")

Interpolação de strings não é permitada neste caso. A função match retorna nada se o padrão (um comando) não é achado, e retorna um objeto regexmatch caso contrário.

Nós podemos extrair a seguinte informação de um objeto regexmatch:

  • A substring correspondente inteira: m.match

  • as substrings capturadas como um lista de strings: m.captures

  • o deslocamento no qual toda a correspondência inicia: m.offset

  • os deslocamentos das substrings capturadas como uma lista: m.offsets

julia> m.match
"s"
julia> m.offset
7

Expressões regulares são extremamente poderosas e a página do manual PERL http://perldoc.perl.org/perlre.html fornece todos os detalhes para construir buscas bastante exóticas.

Arrays

Em Listas nós usamos um objeto lista como um container unidimensional com um índice para endereçar seus elementos. No entanto, no Julia as listas são do tipo Array, (em Inglês, não há distinção no nome) que são coleções multidimensionais.

Vamos criar uma matriz 2 por 3 preenchida com zeros:

julia> z = zeros(Float64, 2, 3)
2×3 Array{Float64,2}:
 0.0  0.0  0.0
 0.0  0.0  0.0
julia> typeof(z)
Array{Float64,2}

O tipo desta matriz é um array que guarda pontos flutuantes com duas dimensões.

A função size retorna uma tupla com seus elementos sendo o número de elementos em cada dimensão da matriz:

julia> size(z)
(2, 3)

A função ones constrói uma matriz com valores unitários:

julia> s = ones(String, 1, 3)
1×3 Array{String,2}:
 ""  ""  ""

Uma string unitária é uma string vazia.

Atenção

s não é um array unidimensional:

julia> s ==  ["", "", ""]
false

s é uma matriz linha e ["", "", ""] é uma matriz coluna.

Uma matriz pode ser digitada diretamente usando a barra de espaço para separar os elementos em uma linha e um ponto e vírgula ; para separar as linhas.

julia> a = [1 2 3; 4 5 6]
2×3 Array{Int64,2}:
 1  2  3
 4  5  6

Você pode usar os colchetes para endereçar elementos individuais:

julia> z[1,2] = 1
1
julia> z[2,3] = 1
1
julia> z
2×3 Array{Float64,2}:
 0.0  1.0  0.0
 0.0  0.0  1.0

Fatias podem ser usadas para cada dimensão para selecionar um subgrupo de elementos:

julia> u = z[:,2:end]
2×2 Array{Float64,2}:
 1.0  0.0
 0.0  1.0

O operador . transmite a operação para todas as dimensões:

julia> ℯ.^(im*u)
2×2 Array{Complex{Float64},2}:
 0.540302+0.841471im       1.0+0.0im
      1.0+0.0im       0.540302+0.841471im

Interfaces

O Julia específica algumas interfaces informais para definir comportamentos, isto é, métodos com um objetivo específico. Quando você extende um destes métodos para um tipo, objetos daquele tipo podem ser usados para desenvolver estes comportamentos.

Se algo se parece com um pato, nada como um pato e soa como um pato, então provavelmente é um pato.

Em Mais Um Exemplo nós implementamos a função fib retornando o \(n\)-ésimo elemento da sequência de Fibonnaci.

Percorrer os valores de uma coleção, chamada iteração, é essa interface. Vamos criar um iterando que retorna preguiçosamente a sequência de Fibonacci:

struct Fibonacci{T<:Real} end
Fibonacci(d::DataType) = d<:Real ? Fibonacci{d}() : error("Não é um tipo real!")

Base.iterate(::Fibonacci{T}) where {T<:Real} = (zero(T), (one(T), one(T)))
Base.iterate(::Fibonacci{T}, estado::Tuple{T, T}) where {T<:Real} = (estado[1], (estado[2], estado[1] + estado[2]))

Nós implementamos um tipo parametrizado sem nenhum campo chamado Fibonacci, um construtor externo e dois métodos iterate. O primeiro é chamado para inicializar o iterando e retornar a tupla consistindo do primeiro valor, 0, e o estado. O estado neste caso é uma tupla contendo o segundo e o terceiro valor, 1 e 1.

O segundo é chamado para obter o próximo valor da sequência de Fibonacci e retornar a tupla tendo como primeiro elemento o próximo valor e o segundo elemento o estado que é uma tupla com os dois próximos valores.

Nós podemos usar Fibonacci agora em um laço for:

julia> for e in Fibonacci(Int64)
           e > 100 && break
           print(e, " ")
       end
0 1 1 2 3 5 8 13 21 34 55 89

Parece que alguma mágica aconteceu, mas a explicação é simples. Um laço for em Julia

for i in iter
    # corpo
end

é traduzido para:

próximo = iterate(iter)
while próximo !== nothing
    (i, estado) = próximo
    # corpo
    próximo = iterate(iter, estado)
end

Isto é um bom exemplo de como uma interface bem definida permite uma implementação usar todas as funções que estão cientes da interface.

Utilidades Interativas

Nós já conhecemos o módulo InteractiveUtils em Depuração. A macro @which é somente a ponta do iceberg.

O código Julia é transformado pela biblioteca LLVM para código de máquina em múltiplos passos. Nós podemos diretamente visualizar a saída de cada etapa.

Aqui está um exemplo simples:

function soma_de_quadrados(a::Float64, b::Float64)
    a^2 + b^2
end

O primeiro passo é ver o código em nível mais baixo:

julia> using InteractiveUtils

julia> @code_lowered soma_de_quadrados(3.0, 4.0)
CodeInfo(
1 ─ %1 = Core.apply_type(Base.Val, 2)
│   %2 = (%1)()
│   %3 = Base.literal_pow(:^, a, %2)
│   %4 = Core.apply_type(Base.Val, 2)
│   %5 = (%4)()
│   %6 = Base.literal_pow(:^, b, %5)
│   %7 = %3 + %6
└──      return %7
)

A macro @code_lowered retorna uma lista de representações intermediárias do código que é usado pelo compilador para gerar código otimizado.

O próximo passo é adicionar a informação de tipo:

julia> @code_typed soma_de_quadrados(3.0, 4.0)
CodeInfo(
1 ─ %1 = Base.mul_float(a, a)::Float64
│   %2 = Base.mul_float(b, b)::Float64
│   %3 = Base.add_float(%1, %2)::Float64
└──      return %3
) => Float64

Nós podemos ver o tipo dos resultados intermediários e o valor de retorno é corretamente inferido.

A representação do código a seguir é transformada em código LLVM:

julia> @code_llvm soma_de_quadrados(3.0, 4.0)
;  @ none:2 within `soma_de_quadrados'
define double @julia_soma_de_quadrados_20369(double, double) {
top:
; ┌ @ intfuncs.jl:261 within `literal_pow'
; │┌ @ float.jl:405 within `*'
    %2 = fmul double %0, %0
    %3 = fmul double %1, %1
; └└
; ┌ @ float.jl:401 within `+'
   %4 = fadd double %2, %3
; └
  ret double %4
}

E finalmente, o código de máquina é gerado:

julia> @code_native soma_de_quadrados(3.0, 4.0)
    .text
; ┌ @ none:2 within `soma_de_quadrados'
; │┌ @ intfuncs.jl:261 within `literal_pow'
; ││┌ @ none:2 within `*'
        vmulsd  %xmm0, %xmm0, %xmm0
        vmulsd  %xmm1, %xmm1, %xmm1
; │└└
; │┌ @ float.jl:401 within `+'
        vaddsd  %xmm1, %xmm0, %xmm0
; │└
        retq
        nopl    (%rax)
; └

Depuração

As macros Logging fornecem uma alternativa a andaimes com declarações print:

julia> @warn "Abandone a depuração com printf, vós que entrais aqui!"
┌ Warning: Abandone a depuração com printf, vós que entrais aqui!
└ @ Main REPL[1]:1

As declarações debug não precisam ser removidas da fonte. Por exemplo, em contraste com o @warn acima:

julia> @debug "A soma de alguns valores é $(sum(rand(100)))"

Não irá gerar nenhuma saída por padrão. Neste caso sum(rand(100)) nunca ira ser avaliada a não ser que o debug logging esteja habilitado.

O nível de logging pode ser selecionado por uma variável de ambiente chamada JULIA_DEBUG:

$ JULIA_DEBUG=all julia -e '@debug "A soma de alguns valores é $(sum(rand(100)))"'
┌ Debug:  A soma de alguns valores é 47.116520814555024
└ @ Main none:1

Aqui, nós usamos all para obter toda informação de depuração, mas você também pode escolher gerar somente a saída para um arquivo ou módulo específico.

Glossário

regex

Expressão regular, uma sequência de caracteres que definem um padrão de busca;

matriz

Um array bidimensional.

representação intermediária

Uma estrutura de dados usada internamente pelo compilador para representar código fonte.

código de máquina

Instruções de linguagem que podem ser executadas diretamente por uma unidade central de processamento (CPU) de um computador.

debug logging

Guardar mensagem de depuração em um log

21. Depuração

Na depuração, você deve distinguir os diferentes tipos de erros a fim de rastreá-los mais rapidamente:

  • Os erros de sintaxe são descobertos pelo interpretador quando ele está traduzindo o código fonte para código byte. Eles sinalizam que há algo errado com a estrutura do programa. Exemplo: omitir a palavra-chave end no final de um bloco de função gera a mensagem um tanto redundante ERROR: LoadError: syntax: incomplete: function requires end.

  • Os erros de tempo de execução são produzidos pelo interpretador se algo der errado durante a execução do programa. A maioria das mensagens de erro de tempo de execução inclui as informações sobre o local da ocorrência do erro e quais as funções que estavam sendo executadas. Exemplo: Uma recursão infinita eventualmente gera o erro de tempo de execução ERROR: StackOverflowError.

  • Os erros semânticos são problemas com um programa que embora rode sem produzir mensagens de erro, não faz a coisa certa. Exemplo: Uma expressão pode não ser avaliada na ordem que você espera, levando a um resultado incorreto.

O primeiro passo na depuração é descobrir com qual o tipo de erro você está lidando. Apesar das seções seguintes serem organizadas pelo tipo de erro, algumas técnicas são aplicáveis em mais de uma situação.

Erros de Sintaxe

Geralmente os erros de sintaxe são fáceis de corrigir depois de você descobrir quais são eles. E infelizmente as mensagens de erro muitas vezes não são úteis. As mensagens mais comuns são ERROR: LoadError: syntax: incomplete: premature end of input e ERROR: LoadError: syntax: unexpected "=", que não são muito informativas.

Por outro lado, a mensagem te informa onde o problema ocorreu no programa. Na verdade, ela informa onde o Julia notou um problema, que não é necessariamente onde está o erro. Às vezes, o erro é anterior ao local da mensagem de erro, em geral na linha anterior.

Se você está construindo o programa de forma incremental, deve ter uma boa ideia de onde o erro está. Ele estará na última linha que você adicionou.

Caso você esteja copiando o código de um livro, comece comparando seu código com o código do livro com muito cuidado. Verifique todos os caracteres. Ao mesmo tempo, lembre-se de que o livro pode estar errado, portanto, se você perceber algo que parece um erro de sintaxe, talvez seja.

Eis algumas maneiras de evitar os erros de sintaxe mais comuns:

  1. Certifique-se que você não está usando uma palavra-chave do Julia para um nome de variável.

  2. Certifique-se que você tem a palavra-chave end no final de cada comando composto, incluindo os blocos for, while, if, e function.

  3. Assegure-se que quaisquer strings no código estejam entre aspas correspondentes. Certifique-se de que todas as aspas são "aspas retas", e não as “aspas encaracoladas” (ou “aspas inglesas”).

  4. Se você tiver strings de múltiplas linhas com aspas triplas, confira se a string foi finalizada corretamente. Uma string não finalizada pode causar um erro de token inválido no final do seu programa, ou pode tratar a parte seguinte do programa como uma string até chegar à próxima string. No segundo caso, ela pode não produzir uma mensagem de erro!

  5. Um operador de abertura não fechado —(, {, ou [—faz com que o Julia continue com a próxima linha como parte do comando atual. Geralmente, um erro ocorre quase imediatamente na próxima linha.

  6. Confira o clássico = em vez de == dentro de um condicional.

  7. Se você tem caracteres não ASCII no código (incluindo as strings e os comentários), isso pode causar um problema, embora o Julia geralmente lide com caracteres não ASCII. Tenha cuidado quando você colar texto de uma página da web ou de outra fonte.

Se nada funcionar, passe para a próxima seção…​

Eu Continuo Fazendo Mudanças e Não Há Diferença

Se o REPL diz que há um erro e você não o vê, talvez seja porque você e o REPL não estão visualizando o mesmo código. Verifique o seu ambiente de programação para garantir que o programa que você está editando é o mesmo que o Julia está tentando executar.

Se você não tiver certeza, tente colocar um erro de sintaxe óbvio e intencional no início do programa. Agora execute-o novamente. No caso do REPL não encontrar o novo erro, então você não está executando o novo código.

Existem alguns prováveis culpados:

  • Você editou o arquivo e esqueceu de salvar as alterações antes de executá-lo novamente. Alguns ambientes de programação fazem isso por você, mas outros não.

  • Você alterou o nome do arquivo, mas ainda está executando o nome antigo.

  • Algo em seu ambiente de desenvolvimento está configurado erroneamente.

  • Se você estiver escrevendo um módulo e usando using, certifique-se de não nomear o seu módulo com o mesmo nome de um dos módulos padrões do Julia.

  • Caso você esteja utilizando using para importar um módulo, lembre-se de que é necessário reiniciar o REPL ao modificar o código no módulo. Se você importar o módulo novamente, ele não faz nada.

Se você ficar preso e não conseguir descobrir o que está acontecendo, uma abordagem é começar novamente com um novo programa como “Olá, Mundo!”, e ter certeza de que você consegue executar um programa conhecido. Depois, acrescente gradualmente as peças do programa original ao novo programa.

Erros de Tempo de Execução

Quando seu programa estiver sintaticamente correto, o Julia poderá lê-lo e, pelo menos, começar a executá-lo. O que poderia dar de errado?

Meu programa Não Faz Absolutamente Nada

Esse problema é mais comum quando seu arquivo consiste de funções e classes, mas não chama uma função para iniciar a execução. Isto pode ser intencional se você planeja importar este módulo apenas para fornecer as classes e as funções.

Se não for intencional, certifique-se que há uma chamada de função no programa, e que o fluxo de execução chega até ele (veja Fluxo de Execução).

Meu Programa Trava

Se um programa para e parece não estar fazendo nada, ele está “travado”. Frequentemente isso significa que ele está detido em um laço infinito ou em uma repetição infinita.

  • Se houver um laço em particular que você suspeita ser o problema, adicione um comando de impressão logo antes do laço que diz “entrando no laço” e outra logo depois que diz “saindo do laço”.

    Execute o programa. Caso receba a primeira mensagem e não a segunda, então você tem um laço infinito. Vá para a subseção Laço Infinito mais adiante.

  • Na maioria das vezes, uma recursão infinita fará com que o programa funcione por um tempo e em seguida gere uma mensagem de erro ERROR: LoadError: StackOverflowError. Se isso acontecer, vá para a subseção Recursão Infinita mais adiante.

    Se você não estiver recebendo esse erro, mas desconfia que há um problema com um método ou uma função recursiva, você ainda pode usar as técnicas descritas em Recursão Infinita.

  • No caso de nenhum desses passos dar certo, comece a testar outros laços e outras funções e métodos recursivos.

  • Caso isso não funcione, então é possível que você não esteja entendendo o fluxo de execução do seu programa. Vá para Fluxo de Execução mais adiante.

Laço Infinito

Se você acha que tem um laço infinito e acha que sabe qual laço está causando o problema, adicione um comando de impressão no final do laço que imprime os valores das variáveis na condição e o valor da condição.

Por exemplo:

while x > 0 && y < 0
    # faça algo para x
    # faça algo para y
    @debug "variáveis" x y
    @debug "condições" x > 0 && y < 0
end

Agora, quando você executar o programa no modo de depuração, verá o valor das variáveis e a condição a cada iteração do laço. A última vez que o laço for percorrido, a condição deve ser false. Se o laço continuar, você poderá ver os valores de x e y e poderá descobrir por que eles não estão sendo atualizados corretamente.

Recursão Infinita

Na maioria das vezes, a recursão infinita faz com que o programa funcione por um tempo e em seguida gere uma mensagem de erro ERROR: LoadError: StackOverflowError.

Se você desconfia que uma função está causando uma recursão infinita, certifique-se que há um caso base. Deve haver alguma condição que causa o retorno da função sem fazer uma chamada recursiva. Caso contrário, você precisa repensar o algoritmo e identificar um caso base.

Se existe um caso base mas o programa não parece alcançá-lo, adicione um comando de impressão no começo da função para imprimir os parâmetros. E quando você executar o programa, verá algumas linhas de saída toda vez que a função for chamada, e verá também os valores dos parâmetros. Se os parâmetros não se moverem em direção ao caso base, você terá algumas idéias sobre o porquê disso ocorrer.

Fluxo de Execução

Se você não tem certeza de como o fluxo de execução está se movendo pelo seu programa, adicione comandos de impressão no início de cada função com uma mensagem como “entrando na função foo”, sendo foo o nome da função.

E quando você executar o programa, um rastro de cada função que for chamada será exibido.

Quando Executo o Programa, Recebo uma Exceção

Se algo der errado durante o tempo de execução, o Julia imprime uma mensagem que inclui o nome da exceção, a linha do programa onde o problema ocorreu e um rastreamento de pilha.

O rastreamento de pilha identifica a função que está em execução no momento, e depois a função que a chamou, e depois a função que chamou essa e assim por diante. Em outras palavras, ele rastreia a sequência de chamadas de função que o levaram aonde você está, juntamente com o número da linha no seu arquivo onde cada chamada ocorreu.

O primeiro passo é examinar o local no programa onde ocorreu o erro e verificar se você consegue descobrir o que aconteceu. Listamos alguns dos erros de tempo de execução mais comuns:

ArgumentError

Um dos argumentos para uma chamada de função não está no estado esperado.

BoundsError

Uma operação de indexação em uma lista que tentou acessar um elemento fora dos limites.

DomainError

O argumento para uma função ou construtor está fora do domínio válido.

DivideError

Tentativa de divisão inteira por um denominador de valor 0.

EOFError

Não havia mais dados disponíveis para a leitura de um arquivo ou fluxo.

InexactError

Não é possível converter exatamente para um tipo.

KeyError

Uma operação de indexação em um objeto do tipo AbstractDict (Dict) ou Set tentou acessar ou apagar um elemento inexistente.

MethodError

Um método com a assinatura de tipo requerida não existe na função genérica em questão. Como alternativa, não existe um método mais específico.

OutOfMemoryError

Uma operação com muita memória alocada tanto para o sistema quanto para o coletor de lixo para manusear corretamente.

OverflowError

O resultado de uma expressão é muito grande para o tipo especificado e causará uma explosão.

StackOverflowError

A chamada de função cresceu além do tamanho da pilha de chamadas. Isso geralmente acontece quando uma chamada cai em uma recursão infinita.

StringIndexError

Ocorrência de um erro ao tentar acessar um índice inválido em uma string.

SystemError

Uma chamada de sistema falhou com um código de erro.

TypeError

Uma falha de asserção de tipo ou chamada de uma função intrínseca com um tipo de argumento incorreto.

UndefVarError

Um símbolo no escopo atual que não está definido.

Adicionei Tantos Comandos de Impressão que Sou Inundado com a Saída

Um dos problemas com o uso dos comandos de impressão para a depuração é que você pode acabar soterrado pelas mensagens na saída. Existem duas maneiras de proceder: simplificar a saída ou o programa.

Para simplificar a saída, você pode remover ou comentar os comandos de impressão que não estão ajudando, ou combiná-los, ou formatar a saída para facilitar a compreensão.

Para simplificar o programa, existem muitas coisas que se pode fazer. Primeiro, reduza o problema no qual o programa está trabalhando. Por exemplo, se você estiver fazendo uma busca em uma lista, busque em uma lista pequena. No caso do programa receber a entrada do usuário, passe a entrada mais simples que cause o problema.

Segundo, limpe o programa. Remova o código morto e reorganize o programa para torná-lo o mais fácil possível de ler. Por exemplo, se você suspeita que o problema está em uma parte profundamente aninhada do programa, tente reescrever essa parte com uma estrutura mais simples. Mas se você suspeitar de uma função grande, tente dividi-la em funções menores e testá-las separadamente.

Freqüentemente, o processo de encontrar o menor caso de teste leva você ao erro. Se você achar que um programa funciona em uma situação, mas não em outra, isso lhe dará uma pista sobre o que está acontecendo.

Da mesma forma, reescrever uma parte do código pode te ajudar a encontrar os erros sutis. Se você fizer uma mudança que você acha que não deve afetar o programa, e ela afeta, isso pode te dar uma dica.

Erros Semânticos

De certa forma, os erros semânticos são os mais difíceis de depurar, porque o interpretador não fornece informações sobre o que está errado. Só você sabe o que o programa deve fazer.

O primeiro passo é conectar o texto do programa ao comportamento que você está vendo. Você precisa de uma hipótese sobre o que o programa está realmente fazendo. Um dos fatores que dificulta isso é que os computadores executam muito rápido.

Muitas vezes você vai desejar diminuir a velocidade do programa para a velocidade humana. Inserir alguns bem colocados comandos de impressão é muitas vezes mais rápido do que configurar um depurador, inserir e remover pontos de interrupção e “andar” pelo programa até onde o erro está ocorrendo.

Meu Programa Não Funciona

Você deve fazer estas perguntas:

  • Existe algo que o programa deveria fazer, mas parece que não está fazendo? Encontre a seção do código que executa essa função e verifique se ela está executando quando você acha que deve.

  • Está acontecendo algo que não deveria? Encontre o código no seu programa que executa essa função e veja se ela está sendo executada quando não deveria.

  • Uma seção do código resulta em algo que não é o que você esperava? Certifique-se que você entende o código em questão, especialmente se ele envolve as funções ou os métodos em outros módulos do Julia. Leia a documentação para as funções que você chama. Experimente-as escrevendo casos de teste simples e verificando os resultados.

Para programar, é preciso um modelo mental de como os programas funcionam. Se você escreve um programa que não faz o que você deseja, com frequência o problema não está no programa e sim, no seu modelo mental.

A melhor maneira de reparar o seu modelo mental é particionar o programa em seus componentes (geralmente as funções e os métodos) e testar cada componente isoladamente. Uma vez encontrada a discrepância entre o seu modelo e a realidade, você pode resolver o problema.

É claro que você deve criar e testar componentes à medida que desenvolve o seu programa. Então ao encontrar um problema, deve haver apenas uma pequena quantidade de código novo que não se sabe se está ou não correto.

Tenho uma Grande Expressão Bizarra e Ela Não Faz o Que Eu Espero

Escrever expressões complexas é bom desde que sejam legíveis, mas podem ser difíceis de depurar. Muitas vezes é uma boa ideia dividir uma expressão complexa em uma série de atribuições a variáveis temporárias.

Por exemplo:

adicionar_carta(jogo.mãos[i], remover_carta(jogo.mãos[achar_vizinho(jogo, i)]))

pode ser rescrito como:

vizinho = achar_vizinho(jogo, i)
carta_escolhida = remover_carta(jogo.mãos[vizinho])
adicionar_carta(jogo.mãos[i], carta_escolhida)

A versão explícita é mais fácil de ler, já que os nomes das variáveis fornecem documentação adicional, e mais fácil de depurar, porque você pode verificar os tipos das variáveis intermediárias e exibir os seus valores.

Outro problema que pode ocorrer com as grandes expressões é que a ordem da avaliação pode não ser a que se espera. Por exemplo, se você estiver traduzindo a expressão \(\frac{x}{2\pi}\) para o Julia, pode-se escrever:

y = x / 2 * π

Isto não está correto porque a multiplicação e a divisão têm a mesma precedência e são avaliadas da esquerda para a direita. Portanto, essa expressão calcula \(\frac{x\pi}{2}\).

Uma boa maneira de depurar expressões é adicionando parênteses para tornar explícita a ordem da avaliação:

y = x / (2 * π)

Sempre que você não tiver certeza da ordem da avaliação, use parênteses. O programa não apenas estará correto (no sentido de fazer o que você deseja), como também será mais legível para outras pessoas que não memorizaram a ordem das operações.

Tenho uma Função Que Não Retorna o Que Eu Espero

No caso de uma declaração return com uma expressão complexa, você não poderá imprimir o resultado antes de retornar. Mais uma vez, pode-se usar uma variável temporária. Por exemplo, em vez de:

return remove_combinações(jogo.mãos[i])

você poderia escrever:

contagem = remove_combinações(jogo.mãos[i])
return contagem

Agora você tem a oportunidade de mostrar o valor de contagem antes de retornar.

Estou Muito, Muito Empacado e Preciso de Ajuda

Primeiro, tente ficar longe do computador por alguns minutos. Trabalhar com um computador pode causar estes sintomas:

  • Frustração e raiva.

  • Crenças supersticiosas (“o computador me odeia”) e o pensamento mágico (“o programa só funciona quando eu uso meu chapéu para trás”).

  • Programação aleatória (a tentativa de programar escrevendo todos os programas possíveis e escolhendo o que faz a coisa certa).

Caso você esteja sofrendo algum desses sintomas, levante-se e dê um passeio. No momento que se acalmar, pense no programa. O que isso está fazendo? Quais são algumas das causas possíveis desse comportamento? Quando foi a última vez que você teve um programa funcional e o que fez a seguir?

Às vezes leva tempo para encontrar um erro. Muitas vezes encontro os erros quando estou longe do computador e deixo a minha mente vaguear. Alguns dos melhores lugares para encontrar os erros são os trens, os chuveiros, e na cama, pouco antes de dormir.

Não, Eu Realmente Preciso de Ajuda

Acontece. Mesmo os melhores programadores ocasionalmente ficam empacados. Às vezes você trabalha em um programa por tanto tempo que não consegue ver o erro. E precisa de um novo par de olhos.

Antes de trazer alguém, esteja preparado. Seu programa deve ser o mais simples possível e você deve trabalhar na menor entrada que causa o erro. Você também deve ter comandos de impressão nos locais apropriados (e as saídas geradas devem ser compreensíveis). Além disso, deve-se entender bem o problema para descrevê-lo de forma concisa.

Ao trazer alguém para te ajudar, não deixe de fornecer as informações de que eles precisam:

  • Se houver uma mensagem de erro, qual é e para qual parte do programa indica?

  • Qual foi a última coisa que foi feita antes deste erro aparecer? Quais foram as últimas linhas de código escritas, ou qual é o novo caso de teste que falha?

  • O que você tentou até agora, e o que você aprendeu?

Ao encontrar o erro, pense um pouco no que você poderia ter feito para encontrá-lo mais rapidamente. Da próxima vez que vir algo semelhante, poderá encontrar o erro com mais agilidade.

Lembre-se, o objetivo não é apenas fazer o programa funcionar. O objetivo é aprender como fazer o programa funcionar.

Apêndice A: Entrada Unicode

A tabela a seguir lista alguns caracteres Unicode de muitos que podem ser introduzios usando completamente por TAB de abreviações tipo LaTeX no REPL do Julia (e em vários editores).

Caractere Sequência de completamento com TAB representação ASCII

²

\^2

\_1

\_2

🍎

\:apple:

🍌

\:banana:

🐫

\:camel:

🍐

\:pear:

🐢

\:turtle:

\cap

\equiv

===

\euler

\in

in

\ge

>=

\le

<=

\ne

!=

\notin

π

\pi

pi

\subseteq

ε

\varepsilon

Apêndice B: Editores online

Listamos algumas alternativas para a execução de código Julia online aqui. A lista não é exaustiva. Nossa preferência é o Repl.it, por ser o recomendado pelo organização do Julia.

Apêndice C: Diferenças na tradução

Esta é uma tradução do ThinkJulia. Tanto o original quanto este livro estão disponíveis sob a licença Creative Commons Atribuição-NãoComercial 3.0 Não Adaptada.

As diferenças significatives do ThinkJulia para esta versão são:

  • Esta versão é uma tradução do inglês para o português brasileiro.

  • Muitas frases idiomáticas foram adaptadas ou removidas, por não fazerem sentido literal no Brasil.

Index


1. Reeves, Byron, e Clifford Ivar Nass. 1996. “The Media Equation: How People Treat Computers, Television, and New Media Like Real People and Places.” Chicago, IL: Center for the Study of Language and Information; New York: Cambridge University Press.