Java 25
Por Vinícius Dias
Esta versão do texto assume Java 25 (a versão de suporte longo lançada em setembro de 2025) ou mais recente. A seção “Instalação” do capítulo 1 traz as instruções para instalar ou atualizar o JDK com o SDKMAN, e o restante do capítulo explica como as versões do Java funcionam.
O formato HTML deste livro é gerado com o mdBook. O livro aceita contribuições da comunidade em seu repositório no GitHub, acessível também pelo ícone no topo da página.
Sobre esta edição
A versão 1.0 deste livro foi inteiramente gerada por inteligência artificial, sob direção humana. A geração não foi livre: o texto foi produzido sob um contrato de estilo explícito, com verificação mecânica a cada capítulo, e todos os capítulos passaram por extensa revisão humana, que definiu o escopo, a ordem, o domínio dos exemplos e corrigiu o que a máquina errou. O registro fica aqui por honestidade com quem lê: nenhuma parte do texto finge uma origem que não tem.
Com as contribuições da comunidade e com escrita minha, porque sempre gostei de escrever, virá a versão 2.0, mais humanizada e mais revisada. A 1.0, ainda assim, precisava vir ao mundo: eu legitimamente senti falta de aprender Java por um livro atualizado, e não encontrei no mercado uma abordagem que atendesse a todas as minhas necessidades pessoais, que bem podem ser as suas.
Boa sorte a todos que vão aprender com este livro.
Prefácio
Este é um livro de Java para quem parte do zero. Ele não assume que o leitor já programou em outra linguagem, não assume vocabulário prévio de computação e não assume ferramenta nenhuma além de um computador com terminal. O ponto de chegada é o núcleo da linguagem, o que se costuma chamar de Java core: a linguagem em si, a orientação a objetos como o Java a pratica, a biblioteca padrão nas partes que todo programa usa, testes, e a capacidade de ler e escrever um programa de verdade. O livro é uma experiência completa em si: termina com um sistema real, construído do zero e funcionando no terminal, e nada nele depende de um passo seguinte. Quem quiser seguir adiante encontra o caminho preparado, porque os frameworks de aplicação, como o Spring, são o rumo mais comum das carreiras Java, e o penúltimo capítulo constrói à mão o mecanismo central deles. Quem não quiser não perde nada: esse mesmo capítulo vale por si, como técnica de organizar programas grandes.
São vinte e cinco capítulos e cinco apêndices. A ordem não é temática, é de
dependência: cada capítulo assume todos os anteriores, cada termo técnico é
definido uma única vez, no capítulo que o apresenta, e usado livremente dali
em diante. Por isso a leitura é sequencial, e pular um capítulo cobra o preço
nos seguintes. Os apêndices são os únicos desviáveis: dependem do livro
inteiro, mas nada no livro depende deles. O primeiro leva o projeto do livro
para um banco de dados com JDBC, e é o único ponto em que o livro assume
conhecimento de fora, a saber, SQL e um banco configurado; ele declara isso na
própria abertura, junto com material de referência para quem precisar. Os
três seguintes constroem interfaces gráficas com JavaFX. O último é um guia
de leitura do Java legado: as grafias antigas, como System.out.println e a
data da era anterior, que continuam por toda parte no código existente e que
o leitor precisa reconhecer sem estranhar.
O mercadinho
Este livro tem um fio condutor: um mercadinho de bairro, com produtos e preços, estoque, lotes e validades, vendas, a caderneta de fiado e relatórios. Ele começa no capítulo 7, quando a linguagem passa a ter peças para construir um sistema, cresce capítulo a capítulo dali em diante e culmina no capítulo 25 como um programa completo de terminal; nos apêndices, vai para um banco de dados e ganha tela.
O mercadinho não é o único exemplo do livro, e não tenta ser. Cada conceito é ensinado com o exemplo que o mostra com mais clareza, venha ele de onde vier. O que o mercadinho garante é a acumulação: em cada capítulo, do 7 ao 24, o material novo também é aplicado ao sistema que já existe, e é nessa aplicação que aparece o que exemplos avulsos não mostram. Exemplo desconexo nasce limpo e morre na mesma página; num sistema que continua, uma escolha apressada no capítulo 7 cobra seu preço no capítulo 15, e consertá-la exige mexer em código que já existia. Aprender a conviver com isso é parte do que o livro ensina.
Como ler este livro
O livro fala com o leitor em notações fixas, e vale conhecê-las antes do primeiro capítulo.
Blocos de código aparecem de dois tipos. Os de fonte Java mostram o conteúdo
de um arquivo, e são sempre completos ou têm a omissão marcada. Os de terminal
mostram uma sessão real: as linhas que começam com $ são o que se digita
(sem o $), e as linhas sem $ são a resposta da máquina, copiada sem
edição. Mensagens de erro, em particular, aparecem por extenso, porque
aprender a lê-las é conteúdo do livro, não ruído.
Em pontos escolhidos aparecem diagramas: caixas ligadas por setas com rótulos, lidas na direção das setas. Um diagrama resume o que a prosa acabou de dizer, nunca a substitui, e capítulo sem necessidade de diagrama não tem nenhum.
Além do texto corrido, quatro caixas aparecem ao longo dos capítulos. Cada uma se apresenta abaixo com a mesma aparência que terá lá dentro:
Esta caixa mostra um trecho de código e faz uma pergunta, em geral sobre o que será impresso, antes de revelar a resposta. Ela só funciona com participação: pare, decida a resposta de verdade, de preferência por escrito, e só então continue. Acertar confirma o modelo mental; errar vale mais ainda, porque expõe exatamente a peça que estava montada errado, no momento em que corrigi-la ainda é barato. A resposta vem sempre logo depois da caixa. Ler a pergunta e deslizar para a resposta desperdiça o instrumento.
Esta caixa marca comportamento da linguagem que parece uma coisa e faz outra, produzindo defeito silencioso: o programa roda, não avisa, e o resultado está errado. Na vida prática, é dessa família que saem os defeitos que atravessam testes e chegam a quem usa o programa, e por isso a caixa é cobrada nos exercícios.
Esta caixa é contexto: explica um porquê, um mecanismo interno, uma história. Muda o entendimento, não muda o que se escreve. Pode ser pulada sem quebrar a sequência do livro e nenhum exercício depende dela.
Esta caixa acompanha as raras comparações do livro com coisas de fora da computação. Toda analogia mente a partir de algum ponto; a caixa declara o ponto. O livro prefere explicação direta a analogia, e quando abre essa exceção, o limite vem junto.
Cada capítulo abre com um problema concreto e fecha com duas seções fixas. A Prática traz exercícios sem resposta publicada, de propósito: o critério de acerto (o programa compila? imprime o que a previsão dizia?) está na máquina do leitor, e exercício com gabarito ao lado treina conferência, não construção. A Ficha resume em tabelas os comandos e termos do capítulo, para consulta rápida quando um capítulo posterior os reutilizar.
O que é preciso ter
Um computador com Windows, Linux ou macOS, um terminal, um editor de texto qualquer e um JDK da versão 25 ou mais nova; o capítulo 1 explica o que é um JDK, de onde baixar e como conferir a versão. Antes do capítulo 14, nenhum outro programa é necessário.
Terminal é o programa de conversa em texto com o sistema: comandos digitados, respostas impressas, e é nele que tudo neste livro compila e roda. No macOS ele vem instalado com o nome Terminal; no Linux, com Terminal em algum lugar do nome, variando por distribuição; no Windows, o PowerShell faz esse papel até o capítulo 1 recomendar o WSL, que traz o terminal Linux para dentro do Windows.
Um ambiente integrado de desenvolvimento vai ser útil mais adiante, mas os primeiros capítulos são feitos deliberadamente no terminal. IDE, de integrated development environment, é o nome desses programas que reúnem editor, compilador e depurador, como o IntelliJ IDEA e o Eclipse. A IDE esconde exatamente os comandos que os primeiros capítulos ensinam, e quem aprende primeiro o que a ferramenta esconde depois a usa entendendo o que ela faz; o contrário não acontece.
O livro usa Java 25 porque é a versão de suporte longo mais recente na escrita, e porque um recurso dela, explicado no capítulo 2, deixa os primeiros programas do tamanho que programas de primeiro capítulo deveriam ter tido desde sempre.
A linguagem, a plataforma e as versões
89 c8
01 d0
c3
Um processador da família x86, a que equipa a maior parte dos computadores de mesa e dos servidores, recebe ordens nesse formato. Cada linha acima é uma instrução: a primeira copia um valor de um lugar para outro dentro do processador, a segunda soma dois valores, a terceira devolve o resultado ao ponto que pediu a soma. Esse formato tem nome: código de máquina, e é a única coisa que um processador executa. Tudo o que um computador faz, de abrir uma página a calcular um boleto ou gravar um arquivo, termina, em algum nível, em sequências como essa.
A própria escrita dessas linhas, porém, usa duas convenções que precisam ser desfeitas antes de qualquer outra coisa, porque as duas reaparecem pela vida inteira de quem programa. Um processador é um circuito elétrico, e um circuito distingue com segurança apenas dois estados: tensão presente ou tensão ausente. Toda informação que um computador guarda, transmite ou executa é, por isso, uma sequência desses dois estados, anotados no papel como 0 e 1. Cada posição de uma sequência dessas é um bit, a menor unidade de informação que existe; a escrita que usa apenas os símbolos 0 e 1 chama-se notação binária. Em notação binária, as três instruções da abertura têm a forma que existe de fato dentro da máquina:
10001001 11001000
00000001 11010000
11000011
Ler, escrever e conferir zeros e uns em quantidade é impraticável para uma
pessoa, e por isso quase nenhum material técnico os mostra assim. A abertura
usou a notação hexadecimal: um sistema de escrita de números com dezesseis
símbolos (os algarismos de 0 a 9, seguidos das letras de a a f) no lugar dos
dez algarismos da notação decimal, a da vida cotidiana. A escolha do dezesseis
tem motivo: quatro bits admitem 2 × 2 × 2 × 2 = dezesseis combinações, uma
para cada símbolo, de modo que cada símbolo hexadecimal corresponde
exatamente a quatro bits, e um par de símbolos descreve um grupo de oito bits,
chamado byte, a unidade em que quase tudo em computação é contado, de tamanho
de arquivo a memória. O 89 da primeira linha da abertura e o 10001001 da
primeira linha acima são o mesmo número: cento e trinta e sete, em notação
decimal. Entre as três escritas muda a notação, nunca a quantidade. E a
quantidade, aqui, nem é o que interessa: o processador não a trata como
número, e sim como ordem a cumprir. Notação de número volta ao livro no
capítulo 3, quando os valores que um programa manipula ganham forma escrita em
Java.
Duas propriedades do código de máquina explicam por que ninguém constrói sistemas escrevendo essas linhas diretamente. A primeira é a escala: somar dois números são três instruções; emitir uma nota fiscal são milhões, e um texto de milhões de números não pode ser lido, revisado nem corrigido por uma pessoa. A segunda é a dependência do processador: cada família tem seu próprio conjunto de instruções, e as três linhas acima, levadas a um processador ARM, o dos celulares e de parte dos notebooks atuais, não significam nada. Um programa escrito assim vale para uma família de máquina e precisa ser reescrito para cada outra.
Uma linguagem de programação existe para atravessar essa distância. É uma notação de texto com regras exatas, desenhada para dois leitores ao mesmo tempo: a pessoa que escreve e mantém o texto, e o programa que traduz esse texto para uma forma que a máquina executa. Na linguagem deste livro, uma soma se escreve assim:
total = preco + multa;
O texto escrito em uma linguagem de programação chama-se código-fonte. O programa que lê código-fonte, confere se ele segue as regras da linguagem e produz a forma executável chama-se compilador.
“Regras exatas” tem um sentido preciso, e é ele que separa uma linguagem de programação de uma língua natural. Para cada texto possível, ou o texto pertence à linguagem e tem um único significado, ou não pertence, e então o compilador o recusa, apontando onde parou de entender. Não existe interpretação aproximada, não existe “deu para entender o que se quis dizer”. A consequência prática aparece no primeiro dia e não vai embora: o computador executa o que está escrito, não o que se pretendia escrever. Um texto quase correto não produz um programa quase correto; produz uma recusa do compilador ou, pior, um programa que faz, sem avisar ninguém, exatamente a coisa errada que está escrita.
O que é Java
Java é uma linguagem de programação publicada em 1995 pela Sun Microsystems, empresa comprada pela Oracle em 2010. Hoje a linguagem é desenvolvida em um projeto de código aberto (isto é, com o código-fonte publicado, para qualquer um ler e propor mudança), o OpenJDK, do qual participam Oracle, Amazon, Microsoft, Red Hat e outras empresas, e evolui por um processo público de propostas: qualquer mudança na linguagem começa como um documento numerado, discutido em aberto antes de virar código.
O nome, porém, cobre mais do que a notação do texto. O que se instala e se usa sob o nome Java são três coisas. A primeira é a linguagem propriamente dita: as regras que dizem o que é um texto Java válido e o que cada construção significa. A segunda é a biblioteca padrão: um conjunto extenso de código pronto, distribuído junto com a linguagem, para as tarefas que quase todo programa tem, como imprimir no terminal, ler e gravar arquivos, medir tempo e conversar com a rede. Boa parte do trabalho de escrever Java é chamar essa biblioteca, e boa parte deste livro é apresentá-la.
A terceira é a plataforma de execução, e é ela que distingue Java da descrição genérica dada até aqui. O compilador de Java não traduz o código-fonte para o código de máquina de um processador específico. Traduz para uma forma intermediária chamada bytecode: instruções definidas em especificação, não em silício, que cumprem o mesmo papel do código de máquina sem pertencer a nenhum processador real. Quem executa bytecode é a JVM (Java Virtual Machine, a máquina virtual Java): um programa instalado em cada máquina, que lê essas instruções e as cumpre usando o processador que houver ali. Existe uma JVM para x86 com Windows, outra para ARM com macOS, e assim por diante, todas lendo o mesmo bytecode. A consequência é o que define a plataforma: o mesmo programa Java, compilado uma vez, roda em qualquer sistema que tenha uma JVM; o que muda de uma máquina para outra é a JVM instalada nela, não o programa. O capítulo 2 põe as duas peças para funcionar no terminal, com os comandos que compilam e executam.
Essa arquitetura, somada a um compromisso incomum de compatibilidade (código compilado há vinte anos continua rodando nas versões atuais), explica onde Java está: em sistemas bancários, no comércio eletrônico de grande porte, em ferramentas de infraestrutura que sustentam outros sistemas, e na origem dos aplicativos Android, que usam a linguagem com outra plataforma de execução. Java é uma escolha frequente para sistemas que precisam funcionar por décadas e ser mantidos por pessoas que não estavam lá quando eles começaram. As decisões de projeto da linguagem, inclusive as que geram reclamação, fazem sentido lidas contra esse requisito.
O JDK e as distribuições
Desenvolver em Java exige instalar um único conjunto de programas: o JDK, sigla
de Java Development Kit. Dele fazem parte o compilador de Java, a JVM e a
biblioteca padrão. Instalado o JDK, o terminal passa a ter o comando java, e
pedir a versão a ele é a primeira verificação a fazer em qualquer máquina:
$ java -version
openjdk version "25.0.4" 2026-07-15 LTS
OpenJDK Runtime Environment Corretto-25.0.4.7.1 (build 25.0.4+7-LTS)
OpenJDK 64-Bit Server VM Corretto-25.0.4.7.1 (build 25.0.4+7-LTS, mixed mode, sharing)
O número no começo da primeira linha é a versão, 25 neste caso. O nome Corretto, nas linhas seguintes, identifica a distribuição, e entender por que existem várias exige apresentar a origem comum de todas. OpenJDK é o projeto de código aberto onde a plataforma Java é desenvolvida: o código-fonte do compilador, da JVM e da biblioteca padrão mora ali, e é ali que as propostas públicas citadas na seção anterior viram código. O OpenJDK é também a implementação-padrão da plataforma, a chamada reference implementation: quando a especificação de uma versão fica pronta, é o código dele que a realiza e contra o qual as demais são conferidas.
Só que código aberto não é código pronto para usar: alguém precisa compilá-lo para cada sistema, testar, assinar o resultado e, sobretudo, continuar publicando correções por anos, e esse trabalho contínuo custa dinheiro. Cada empresa com interesse em Java o financia à sua maneira e distribui o resultado, que é o que se chama de distribuição. A Amazon mantém o Corretto porque roda Java em escala nos próprios servidores; a Microsoft faz o mesmo para a nuvem dela; a Oracle vende suporte sobre o Oracle JDK; a Eclipse Foundation publica o Temurin como distribuição comunitária, sem dono comercial; a Azul vive de suporte ao seu Zulu. Todas partem do mesmo código de origem, e por isso a linguagem, a biblioteca padrão e o comportamento dos programas são os mesmos em qualquer uma; o que muda é quem publica correções, por quanto tempo e sob quais termos de licença. Para este livro, qualquer distribuição serve, e trocar de uma para outra não muda uma linha do que vem pela frente.
Instalação
Cada distribuição tem instalador próprio, e qualquer um funciona. Este livro recomenda um caminho único, válido para Linux e macOS: o SDKMAN, um gerenciador que instala, lista e troca versões de JDK pelo terminal. Dois comandos o instalam, e um terceiro instala o JDK:
$ curl -s "https://get.sdkman.io" | bash
$ source "$HOME/.sdkman/bin/sdkman-init.sh"
$ sdk install java 25.0.4-amzn
O identificador 25.0.4-amzn nomeia a correção 25.0.4 do Corretto, a
distribuição da Amazon, exatamente a instalação da máquina em que este livro
roda: instalado por esse caminho, java -version responde as mesmas linhas
do exemplo acima. O número exato muda a cada trimestre; o comando abaixo
mostra os identificadores disponíveis no dia, e qualquer um que comece com
25, ou mais novo, atende ao livro:
$ sdk list java
================================================================================
Available Java Versions for Linux 64bit
================================================================================
Vendor | Use | Version | Dist | Status | Identifier
--------------------------------------------------------------------------------
Corretto | >>> | 25.0.4 | amzn | installed | 25.0.4-amzn
| | 21.0.9 | amzn | | 21.0.9-amzn
Java.net | | 26.ea.36 | open | | 26.ea.36-open
Temurin | | 25.0.4 | tem | | 25.0.4-tem
| | 21.0.9 | tem | | 21.0.9-tem
...
A lista real é bem mais longa, com a omissão marcada acima. A primeira coluna
traz a distribuição; a última, o identificador que o sdk install java
recebe. A linha marcada com >>> é a instalação que o comando java usa no
momento. Identificadores com ea no meio, como 26.ea.36, são montagens de
acesso antecipado (early access) da versão ainda em desenvolvimento, e não
atendem ao livro.
Quando houver mais de um JDK na máquina, o SDKMAN também faz a troca:
sdk default java, seguido de um identificador, passa a apontar o comando
java para a instalação escolhida.
No Windows, a recomendação é instalar o WSL (Windows Subsystem for Linux), que
põe um Linux completo dentro do Windows, e seguir o caminho acima dentro dele.
Um comando faz a instalação, em um PowerShell aberto como administrador (o >
faz as vezes do $ no PowerShell):
> wsl --install
Depois de reiniciar a máquina, o terminal do WSL é um terminal Linux comum. A recomendação tem um motivo que vai além deste capítulo: os comandos de terminal deste livro, como os de listar arquivos, mover e criar pastas que o capítulo 2 já usa, são os do mundo Unix, e as saídas mostradas vêm de um sistema assim. Nada disso é obrigatório: o JDK existe para Windows puro e o Java é o mesmo lá. Mas quem estiver em Linux, WSL ou macOS verá na tela exatamente o que o livro mostra.
Como as versões funcionam
Até 2017, uma versão nova de Java saía quando ficava pronta, em intervalos irregulares: quase cinco anos separam a versão 6 da 7; a 8 é de 2014, a 9 de 2017. Desde a versão 9 o calendário é fixo: uma versão nova em março e outra em setembro, todos os anos, estejam prontas as novidades que estiverem; o que não entrou espera a versão seguinte. O número da versão, portanto, mede posição no calendário, não tamanho de mudança: a distância entre a 24 e a 25 pode ser maior ou menor do que entre a 23 e a 24.
Novidade de linguagem raramente estreia pronta. Entra primeiro como prévia (preview, o nome que aparece na documentação em inglês): disponível para teste, sujeita a mudar de forma, desligada a menos que se peça para ligá-la. Depois de uma ou mais rodadas de prévia, a novidade é finalizada e passa a valer sem pedido nenhum. Este livro se apoia em um recurso que percorreu esse caminho e foi finalizado na versão 25; essa é a razão da exigência de versão feita na prática deste capítulo.
Desde a versão 17, a cada dois anos a versão de setembro recebe a marca LTS, de long-term support, suporte de longo prazo; antes dela o intervalo era maior, como a tabela da ficha registra. Uma versão LTS continua recebendo correções de erros e de segurança por anos; uma versão comum para de recebê-las seis meses depois de lançada, quando a seguinte chega. São LTS as versões 11 (2018), 17 (2021), 21 (2023) e 25 (2025), e também a 8, de 2014, anterior ao calendário atual e ainda viva em muitos sistemas antigos. Empresas que mantêm sistemas em produção em geral ficam nas LTS e pulam de uma para a outra; as versões intermediárias são usadas por quem quer testar as novidades no semestre em que saem. Este livro usa a versão 25.
As correções chegam como números acrescentados ao da versão: 25.0.1, 25.0.2, publicadas de três em três meses. Elas consertam defeitos e fecham brechas de segurança, mas não acrescentam recurso nenhum. O primeiro número é o único que decide o que o código-fonte pode usar.
Nem toda máquina é a deste livro. Num computador herdado de outra pessoa, de um estágio ou de um laboratório, o comando de verificação responde isto:
$ java -version
openjdk version "21.0.8" 2025-07-15 LTS
OpenJDK Runtime Environment Temurin-21.0.8+9 (build 21.0.8+9-LTS)
OpenJDK 64-Bit Server VM Temurin-21.0.8+9 (build 21.0.8+9-LTS, mixed mode)
Três perguntas, todas respondíveis com o que este capítulo apresentou: essa instalação ainda recebe correções? Ela atende ao que este livro exige? E o que precisa acontecer para que atenda — a instalação existente se transforma, ou não?
A primeira resposta é sim: 21 é a LTS de setembro de 2023, e a própria saída
mostra a correção 21.0.8, publicada em julho de 2025, sinal de que a versão
segue mantida. A segunda resposta é não: o livro exige a versão 25 ou mais
nova, e essa instalação está quatro versões e dois anos atrás dela. Receber
correções e estar atualizado são coisas diferentes: a saída acima é de uma
máquina bem cuidada e, ainda assim, insuficiente aqui. A terceira: instalar um
JDK da versão 25 ou mais nova, e nada se transforma sozinho. É uma segunda
instalação, e o comando java do terminal precisa passar a apontar para ela.
Uma máquina com JDK em dia para o sistema que ela roda pode estar anos
atrasada para este livro, e o número na primeira linha é o que decide.
Numeração antiga. Até a versão 8, o número público vinha precedido de
“1.”: java -version imprimia 1.8.0_292 para o que todo mundo chamava de
Java 8. A grafia foi abandonada na versão 9, mas continua aparecendo em
máquinas antigas e em material antigo. 1.8 e 8 são a mesma versão.
Prática
-
Rode
java -versionna máquina em que o livro será acompanhado e anote três coisas: o número da versão, se ela é LTS e se atende ao que o livro pede. Se o comando não existir ou a versão for menor que 25, instale uma distribuição da 25 ou mais nova e repita a verificação. -
Sem consultar nada além do calendário fixo descrito neste capítulo, calcule qual número de versão estará recém-lançado em outubro do ano que vem e em que ano sai a primeira LTS depois da 25. Depois confira no site do OpenJDK.
-
Escolha duas distribuições e compare, nas páginas de cada uma, até quando cada uma promete correções para a versão 25. Anote a diferença.
-
Explique por escrito, em um parágrafo, por que o mesmo programa Java compilado roda em um processador x86 e em um ARM, dizendo qual peça muda de uma máquina para a outra e qual permanece a mesma.
Ficha do capítulo
| Termo | Definição |
|---|---|
| código de máquina | instruções numéricas que um processador executa; específicas de cada família de processador |
| bit | a menor unidade de informação; vale 0 ou 1 |
| byte | grupo de oito bits; a unidade comum de contagem em computação |
| notação binária | escrita de números com dois símbolos, 0 e 1 |
| notação hexadecimal | escrita de números com dezesseis símbolos; cada um corresponde a quatro bits |
| linguagem de programação | notação de texto com regras exatas, escrita por pessoas e traduzida por programa |
| código-fonte | o texto escrito em uma linguagem de programação |
| compilador | programa que lê código-fonte, confere as regras da linguagem e produz a forma executável |
| bytecode | forma intermediária gerada pelo compilador de Java; igual em qualquer máquina |
| JVM | máquina virtual Java: programa instalado em cada máquina, que executa bytecode |
| biblioteca padrão | código pronto para tarefas comuns, distribuído junto com a linguagem |
| JDK | conjunto instalável que reúne o compilador, a JVM e a biblioteca padrão |
| OpenJDK | projeto de código aberto onde o JDK é desenvolvido; a implementação-padrão (reference implementation) da plataforma |
| distribuição | empacotamento do OpenJDK publicado e mantido por uma empresa |
| SDKMAN | gerenciador que instala, lista e troca versões de JDK pelo terminal |
| WSL | Windows Subsystem for Linux: um Linux completo dentro do Windows |
| prévia (preview) | novidade disponível para teste, sujeita a mudança, desligada a menos que se peça |
| LTS | versão com correções por anos; as demais recebem correções por seis meses |
| Versão LTS | Lançamento |
|---|---|
| 8 | 2014 |
| 11 | 2018 |
| 17 | 2021 |
| 21 | 2023 |
| 25 | 2025 |
Hello world e como o Java executa
Um arquivo de três linhas é suficiente para pôr um programa Java de pé:
void main() {
IO.println("Olá, mundo.");
}
O texto vai salvo em um arquivo chamado Ola.java, e um único comando
no terminal o executa:
$ java Ola.java
Olá, mundo.
Esse é o programa inteiro: uma linha impressa e o fim. A partir dele, este
capítulo provoca de propósito as duas falhas que vão acompanhar o leitor pelo
livro inteiro. Trocar uma letra dentro de println produz uma mensagem que
cita o arquivo, a linha e a coluna do problema. Mover um arquivo para uma
subpasta produz outra mensagem, que não cita linha nenhuma e reclama de um
nome. As duas se parecem com “não rodou”, e é exatamente aí que mora a
dificuldade: elas vêm de programas diferentes, acontecem em momentos
diferentes e se consertam de jeitos diferentes. Quem trata as duas como a
mesma coisa procura o defeito no lugar errado, às vezes por horas. Separar
esses dois momentos é o trabalho deste capítulo, e é o que torna legíveis as
mensagens de erro de todos os capítulos seguintes.
As três linhas, uma a uma
A primeira linha declara um método. Um método é um bloco de código que tem
nome: escreve-se o bloco uma vez e ele pode ser executado, pelo nome, quantas
vezes forem necessárias. Declarar um método é escrevê-lo; chamar um método é
fazer a execução entrar nele. As chaves { e } marcam onde o corpo do
método começa e onde termina: tudo o que estiver entre elas é o que acontece
quando o método for chamado, e nada fora delas pertence a ele.
O nome main não foi escolha deste livro. main é o método por onde todo
programa Java começa: de tudo o que um arquivo declara, é esse o nome que a
execução procura e chama primeiro, e o programa termina quando esse método
termina. Um arquivo pode declarar outros métodos com outros nomes, mas a porta
de entrada é sempre essa, e um dos exercícios deste capítulo mostra o que
acontece quando ela não existe.
A palavra void, escrita antes do nome, declara que main não devolve nada a
quem o chamou. Um método pode produzir um resultado e entregá-lo ao ponto que
o chamou, para que a execução continue dali com esse resultado em mãos; void
é a marca de que este método não entrega resultado nenhum. Como um método
devolve, e o que muda no código de quem chama, é assunto do capítulo 4.
A linha do meio é uma chamada. IO.println é um método pronto: vem com o
Java, dentro da biblioteca padrão, e está disponível em qualquer arquivo como
este sem nenhuma linha extra. Ele recebe um argumento, que é o valor escrito
entre os parênteses e entregue ao método no momento da chamada, e escreve esse
valor na saída padrão, seguido de uma quebra de linha. Saída padrão é o canal
por onde um programa de terminal escreve seu resultado comum; é o que o
terminal mostra sem que ninguém peça nada. As aspas duplas marcam onde o texto
a imprimir começa e onde termina, e não aparecem na saída: o programa imprime
Olá, mundo., não as aspas em volta. O ln no fim do nome é a quebra
de linha. Existe também IO.print, sem o ln, que escreve o mesmo valor e
deixa a saída parada na mesma linha. A diferença parece miúda até o primeiro
exercício em que duas mensagens saem grudadas uma na outra.
Resta explicar por que essas três linhas bastam como arquivo. Arquivo-fonte
compacto é o nome do arquivo .java que declara métodos diretamente, como
este. Antes da versão 25, o mesmo programa exigia linhas de moldura em volta
do método, e praticamente todo material publicado até hoje, de livros a
respostas em fórum, mostra essas linhas. O recurso que as dispensa passou por
rodadas de prévia e foi finalizado na versão 25; dela em diante, o compilador
fornece a moldura quando ela não está escrita. O que essa moldura declara, e
por que ela deixa de ser dispensável assim que um programa passa de um
arquivo, é assunto do capítulo 7. Há também uma segunda forma de escrever
main, que recebe o que foi digitado no terminal depois do nome do programa;
essa forma é o capítulo 5.
Duas linhas de um arquivo-fonte podem nunca chegar ao programa. Tudo o que vem
depois de // até o fim da linha, e tudo o que estiver entre /* e */, é
comentário: texto para quem lê o arquivo, que o compilador descarta por
inteiro e que não influencia a execução de nenhuma forma. Os exercícios deste
capítulo pedem descrições por escrito, e o próprio arquivo .java, em
comentários, é um lugar razoável para elas.
A versão do JDK decide se o arquivo compila
O calendário de versões do capítulo 1 cobra aqui sua primeira consequência
prática. O arquivo-fonte compacto só existe, como recurso finalizado, da
versão 25 em diante. Em um JDK anterior, o compilador recusa a primeira linha
do arquivo, e a recusa não menciona main nem IO, o que a torna difícil de
ligar à causa verdadeira. A mesma recusa pode ser reproduzida em um JDK novo,
mandando o compilador seguir as regras de uma versão antiga:
$ javac --release 24 Ola.java
Ola.java:1: error: implicitly declared classes are not supported in -source 24
void main() {
^
(use -source 25 or higher to enable implicitly declared classes)
1 error
Vale conhecer essa mensagem de véspera, porque ela ensina uma regra de leitura: mensagem estranha apontando para a primeira linha de um arquivo que está correto é, quase sempre, sintoma de versão, não de código. Conferir a versão instalada antes de procurar defeito no arquivo evita o desencontro:
$ java -version
openjdk version "25.0.4" 2026-07-15 LTS
OpenJDK Runtime Environment Corretto-25.0.4.7.1 (build 25.0.4+7-LTS)
OpenJDK 64-Bit Server VM Corretto-25.0.4.7.1 (build 25.0.4+7-LTS, mixed mode, sharing)
Da saída interessa o número no começo da primeira linha, que precisa ser 25 ou
maior; o resto identifica a distribuição, um Corretto nesta máquina, e não
muda nada do que este livro faz. A moldura que o capítulo 7 apresenta elimina
a exigência do arquivo-fonte compacto, mas não a da versão: IO.println
também nasceu no 25, e os programas do livro seguem pedindo esse mínimo do
começo ao fim.
Dois programas, dois momentos
O comando da abertura, java Ola.java, fazia dois trabalhos de uma
vez, e enquanto os dois dão certo não há como perceber que são dois. Estes
comandos os separam:
$ javac Ola.java
$ ls
Ola.class Ola.java
$ java Ola
Olá, mundo.
javac é o compilador de Java: o compilador do capítulo 1, agora com nome e
comando próprios. Ele lê o texto de Ola.java, confere se aquilo é
Java válido e grava o resultado da tradução em um arquivo novo, o
Ola.class. O conteúdo desse arquivo não é texto Java nem código de
máquina de processador algum. É bytecode: até aqui uma ideia descrita no
papel, daqui em diante um arquivo concreto no disco, que pode ser copiado,
movido e apagado como qualquer outro. A extensão .class vem do nome de uma
declaração que o capítulo 7 apresenta.
Quem lê e executa esse arquivo é a JVM. O comando java inicia uma, entrega a
ela o bytecode indicado e sai do caminho: dali em diante, quem está rodando é
o programa. As duas ferramentas, javac para traduzir e java para executar,
vêm juntas no JDK, ao lado da biblioteca padrão onde mora IO.println.
A tradução em duas etapas é o mecanismo por trás da portabilidade prometida no
capítulo 1. O Ola.class gerado aqui roda sem alteração em qualquer
máquina que tenha uma JVM da mesma versão ou mais nova, seja ela x86 ou ARM,
Windows, Linux ou macOS. O que se instala em cada máquina é a JVM certa para
ela; o bytecode entregue é o mesmo em todas. Um compilador que traduzisse
direto para o código de máquina produziria um programa mais rápido de iniciar
e preso a uma família de processador; o desenho do Java troca esse arranque
pela portabilidade.
O nome que aparece no comando java também sai desse desenho. Para um
arquivo-fonte compacto, o compilador dá ao bytecode gerado o mesmo nome do
arquivo: Ola.java vira Ola.class, e é Ola, sem
extensão nenhuma, que se escreve depois de java. Renomear o arquivo antes de
compilar muda as duas coisas juntas.
Os dois caminhos:
flowchart TB
F["Ola.java<br/>código-fonte"]
F -->|"javac Ola.java"| C["Ola.class<br/>bytecode em disco"]
C -->|"java Ola"| E["execução na JVM"]
F -->|"java Ola.java<br/>compila e executa na memória"| E
O erro que não deixa nada para trás
O compilador recusa o que não entende, e recusar quer dizer não gravar nada:
$ javac Ola.java
Ola.java:2: error: cannot find symbol
IO.printn("Olá, mundo.");
^
symbol: method printn(String)
location: class IO
1 error
$ ls
Ola.java
Esse é um erro de compilação: o compilador aponta arquivo, linha, coluna e o
que não reconheceu, e o diretório continua sem nenhum Ola.class. A
consequência vale ser dita por inteiro: um erro de compilação nunca alcança
quem usa o programa, porque não há programa a entregar. Tudo o que o
compilador consegue pegar, ele pega antes de existir qualquer coisa
executável, e esse é o motivo de tanta coisa neste livro ser desenhada para
transformar enganos em erros de compilação.
Duas leituras dessa mensagem valem virar hábito. A primeira: cannot find symbol significa que o nome escrito não existe no lugar em que foi procurado;
na prática, um erro de digitação ou um nome que ainda não foi declarado. A
segunda: quando o compilador imprime várias mensagens de uma vez, a primeira
costuma ser a causa real e as demais costumam ser consequência dela. Corrigir
a de cima e compilar de novo economiza mais tempo do que tentar entender todas
de uma vez. Dois nomes nas linhas de detalhe da mensagem ainda não pertencem
ao leitor; os capítulos 5 e 7 os apresentam, e as linhas que importam por
enquanto são as três primeiras.
Nem toda recusa aponta a falta no lugar em que ela está. Retirar o ponto e vírgula do fim da linha produz esta mensagem:
$ javac Ola.java
Ola.java:2: error: ';' expected
IO.println("Olá, mundo.")
^
1 error
A coluna apontada é o fim da linha 2, não o começo da linha 3, porque o compilador só percebe a ausência quando termina de ler o que veio antes. As instruções simples, como a chamada da linha 2, terminam em ponto e vírgula, e as chaves marcam onde o corpo do método começa e acaba: essas duas regras respondem pela maior parte dos erros de compilação da primeira semana, e as mensagens delas nem sempre apontam para onde o dedo iria.
A outra família é a do erro de execução: o que o compilador não tem como prever e que só aparece do lançamento em diante, quando a JVM já está no comando. Alguns acontecem antes mesmo da primeira instrução do programa, como o da previsão a seguir; outros, no meio do trabalho. Os capítulos seguintes apresentam os erros de execução mais comuns, um a um, à medida que as construções que os provocam aparecem; o que este capítulo mostra é o primeiro da lista, e ele nasce de uma pergunta simples: como a JVM encontra o bytecode que deve executar?
O diretório tem o fonte e o bytecode. O bytecode vai para uma subpasta e o comando é repetido sem nenhuma outra mudança:
$ ls
Ola.class Ola.java
$ mkdir saida
$ mv Ola.class saida/
$ java Ola
Ola.java continua ali, intacto, e o programa nele está correto. O que
aparece no terminal?
O terminal responde isto:
Error: Could not find or load main class Ola
Caused by: java.lang.ClassNotFoundException: Ola
O fonte estar no diretório não ajuda em nada, porque java Ola não lê
fonte nenhum: ele executa bytecode já pronto. E Ola, nesse comando,
não nomeia um arquivo; nomeia o que a JVM tem de encontrar. Onde ela procura é
o classpath: a lista de lugares em que a JVM busca bytecode. Quando ninguém
diz nada, essa lista tem um item só, o diretório atual. O arquivo saiu do
diretório atual, então saiu da lista, e a JVM não tem por onde continuar.
Dizer onde procurar resolve:
$ java -cp saida Ola
Olá, mundo.
-cp, que também se escreve -classpath, substitui a lista inteira pelo que
vem depois dele. Vale gravar o formato da mensagem: could not find or load
quase nunca quer dizer que o código não existe. Quer dizer que ele não está em
nenhum dos lugares da lista. Procurar o defeito dentro do fonte, nesse caso, é
procurar onde não está, e essa confusão responde por boa parte das primeiras
horas perdidas de quem começa. A partir do capítulo 14, quem monta o classpath
deixa de ser quem digita o comando e passa a ser a ferramenta de construção do
projeto; até lá, ele é digitado à mão, e digitá-lo à mão algumas vezes é o que
torna compreensível o que a ferramenta fará depois.
Dois comandos, o mesmo diretório, saídas diferentes. O arquivo é compilado
com javac Ola.java; em seguida, no editor, o texto do fonte é trocado por
“Tchau, mundo.”; e os dois comandos rodam:
$ java Ola
Olá, mundo.
$ java Ola.java
Tchau, mundo.
java Ola.java compila o fonte na memória e executa o que acabou de
compilar, sem gravar nada em disco. java Ola ignora o fonte e executa
o Ola.class que encontrar no classpath, que é o de antes da edição. O
sintoma é uma correção que não faz efeito: o código muda, o comando roda sem
erro nenhum, e a saída continua a antiga. Compilar antes de executar, sempre,
é o que elimina essa classe de engano.
JIT. A JVM começa interpretando o bytecode uma instrução por vez e, nos trechos que se repetem muito, traduz esse bytecode para instruções do processador da máquina e guarda o resultado para as próximas passagens. Esse tradutor interno chama-se JIT, de just-in-time. É por isso que um mesmo trecho de código pode ficar mais rápido depois de alguns milhares de execuções, sem que nada tenha mudado no programa.
Prática
-
Escreva um arquivo-fonte compacto que imprima três linhas, uma por chamada de
IO.println, e execute-o sem compilar antes. Depois troque a última chamada porIO.printe descreva por escrito a diferença na saída. -
Compile o arquivo com
javac, apague o.javae execute o programa. Escreva em uma frase o que esse resultado prova sobre o que a JVM precisa ter em mãos. -
Escreva
IO.printnno lugar deIO.printlne compile. Anote arquivo, linha e coluna citados. Depois execute o mesmo fonte comjavadireto e compare as duas mensagens: o que é igual, o que muda e por quê. -
Compile, crie a subpasta
saida, mova para lá o arquivo de bytecode e faça o programa rodar sem devolver o arquivo ao diretório atual. -
Reproduza a armadilha: no mesmo diretório e sem editar nada entre um comando e outro, obtenha duas saídas diferentes. Escreva a menor regra de trabalho que torna esse engano impossível.
-
Escreva um arquivo-fonte compacto cujo único método se chame
principalem vez demain. Descubra em qual dos dois momentos o problema aparece e explique por que ele aparece nesse momento e não no outro.
Ficha do capítulo
| Comando | O que faz |
|---|---|
java Ola.java | compila em memória e executa; não grava bytecode |
javac Ola.java | grava Ola.class e não executa nada |
java Ola | procura o bytecode de nome Ola no classpath e executa |
java -cp saida Ola | mesma coisa, procurando em saida em vez do diretório atual |
| Termo | Definição |
|---|---|
| método | bloco de código com nome, que pode ser chamado por esse nome |
main | método por onde a execução do programa começa |
void | marca de que o método não devolve nada a quem o chamou |
| argumento | valor escrito entre parênteses na chamada e entregue ao método |
IO.println | método pronto que escreve o argumento na saída padrão e quebra a linha |
| saída padrão | canal por onde o programa escreve seu resultado comum no terminal |
| arquivo-fonte compacto | arquivo .java que declara métodos diretamente, sem moldura em volta |
javac | o compilador de Java; grava bytecode e não executa nada |
| classpath | lista de lugares onde a JVM procura bytecode; por omissão, o diretório atual |
| erro de compilação | recusa do compilador; cita arquivo e linha, e nada é gravado |
| erro de execução | falha que o compilador não prevê; encontrada pela JVM, do lançamento em diante |
Variáveis, primitivos e operadores
Quatro linhas dentro de main, salvas em Maioridade.java:
void main() {
int idade = 17;
int ano = 2026;
int maioridade = ano + 18 - idade;
IO.println(maioridade);
}
$ java Maioridade.java
2027
O capítulo 2 imprimia o que já estava escrito no arquivo. Este programa é diferente: o valor 2027 não aparece em linha nenhuma do fonte. Ele nasceu de uma conta feita durante a execução, sobre valores guardados com nome nas duas primeiras linhas. Guardar valores e calcular com eles é o assunto deste capítulo, e as regras desse jogo têm consequências práticas: duas delas produzem resultados errados sem aviso nenhum, e este capítulo as provoca de propósito.
Variáveis
A linha int idade = 17; tem três partes: um tipo, um nome e um valor. Uma
variável é um nome que guarda um valor durante a execução; escrever a linha
acima é declarar a variável, e o sinal = faz a atribuição: calcula o que
está à direita e guarda no nome à esquerda. O = de Java não é a igualdade
da matemática, é uma ordem com direção. A linha seguinte é legal e útil:
idade = idade + 1;
Lida como igualdade, a linha é absurda; lida como ordem, é rotina: calcule
idade + 1 com o valor atual, guarde o resultado de volta em idade. A
variável passa a valer 18. Declarar duas vezes o mesmo nome no mesmo trecho,
por outro lado, é erro de compilação: a declaração acontece uma vez, e as
atribuições seguintes usam só o nome.
O tipo, primeira palavra da declaração, diz que espécie de valor o nome
guarda e o que se pode fazer com ele. int guarda números inteiros; guardar
outra coisa ali é recusado na hora:
$ javac Maioridade.java
Maioridade.java:2: error: incompatible types: possible lossy conversion from double to int
int idade = 17.5;
^
1 error
O compilador leu o tipo declarado, conferiu o valor e recusou antes de existir programa. Essa conferência é o serviço que os tipos prestam: um engano de espécie de valor vira erro de compilação, com arquivo e linha, em vez de virar resultado errado em execução. Boa parte do desenho de Java existe para empurrar enganos nessa direção.
Oito primitivos, cinco em uso
Os tipos mais simples da linguagem chamam-se primitivos: neles, a variável guarda o próprio valor, direto. Existem oito; este livro trabalha com cinco, e os outros três ficam registrados na ficha do capítulo por completude.
Cada primitivo ocupa um número fixo de bits na memória, e é esse número que
determina quantos valores cabem no tipo. O capítulo 1 apresentou o bit como
a menor unidade de informação, com dois estados possíveis; combinar bits
multiplica possibilidades: 2 bits formam 4 combinações, 3 bits formam 8, e
cada bit acrescentado dobra o total, de modo que N bits distinguem 2^N
valores. Um int ocupa 32 bits, o que dá 2³² combinações, cerca de 4,29
bilhões; metade delas fica com os negativos, e daí vem o intervalo de ±2,1
bilhões. Um long ocupa 64 bits, um double também 64, um char 16, e o
boolean é o único cujo tamanho a linguagem não define, deixando a escolha
para a JVM. Como oito bits formam um byte, os mesmos tamanhos aparecem por
aí como 4 bytes para int e 8 para long.
int guarda inteiros de −2.147.483.648 a 2.147.483.647. Um valor escrito
diretamente no código, como o 17 e o 2026 da abertura, chama-se literal.
Java aceita o separador _ dentro de um literal numérico, e ele só existe
para olhos humanos: 2_147_483_647 é o mesmo número. As notações do
capítulo 1 também valem como literal: 0x1F é a escrita hexadecimal e
0b11111 a binária do mesmo 31 decimal. A base muda a escrita no fonte; o
valor guardado é um só.
long guarda inteiros de até cerca de ±9,2 quintilhões (9,2 × 10¹⁸), e seu
literal leva o sufixo L:
8_000_000_000L. É o tipo das contagens que passam dos dois bilhões, como
milissegundos acumulados ou habitantes do planeta.
double guarda números com parte fracionária, escritos com ponto, não com
vírgula: 19.90. O nome do formato é ponto flutuante, e a última seção
deste capítulo mostra o que esse formato esconde.
boolean guarda apenas dois valores, true e false. Ele quase nunca é
escrito como literal no dia a dia: nasce das comparações, apresentadas logo
adiante, e comanda as decisões do capítulo 4.
char guarda um único caractere, entre aspas simples: 'A'. Aspas simples
e aspas duplas não se trocam: "A", com aspas duplas, é um valor de outra
espécie, apresentada no capítulo 5. Por dentro, char é um número. Cada
caractere de cada escrita do mundo tem um código na tabela Unicode, a tabela
que dá um número a cada caractere, e 'A' é o 65. Essa natureza numérica
reaparece daqui a duas seções.
Expressões e operadores
Uma expressão é qualquer trecho de código que produz um valor: 17 é uma
expressão, idade é uma expressão, ano + 18 - idade é uma expressão. Um
operador é o símbolo que combina valores dentro de uma expressão. Os
aritméticos são cinco: +, -, * para multiplicar, / para dividir e
% para o resto da divisão. A precedência é a da escola: *, / e %
antes de + e -, e parênteses decidem qualquer outra ordem. Em caso de
dúvida, parênteses resolvem: não custam nada e eliminam a ambiguidade para o
próximo leitor.
Um rateio simples:
void main() {
int total = 7;
int pessoas = 2;
IO.println(total / pessoas);
IO.println(total % pessoas);
}
Duas linhas de saída. Quais valores aparecem?
3
1
A divisão entre dois int produz um int: a parte fracionária é descartada,
sem arredondamento (7 dividido por 2 daria 3,5; o resultado é 3, e 3,9
viraria 3 do mesmo jeito). O % entrega o que a divisão descartou como
resto inteiro: 2 cabe 3 vezes em 7, sobra 1. O custo de não saber disso é
concreto: média de avaliações, porcentual de desconto e rateio de conta saem
errados, sem erro nenhum na tela, sempre que os dois lados da divisão são
inteiros. O conserto aparece na seção de promoção, logo adiante.
Três atalhos completam o conjunto. Os operadores compostos aplicam a conta
sobre a própria variável: total += 2 soma 2 a total, e -=, *=, /=
seguem o padrão. total++ soma 1 e total-- subtrai 1; este livro os usa
como instrução isolada, que é o uso que não guarda surpresa.
As comparações produzem boolean: == pergunta se dois valores são iguais,
!= se são diferentes, e <, <=, >, >= comparam ordem.
IO.println(idade >= 18) imprime false para a idade da abertura. Vale
fixar a diferença de papéis: = guarda, == compara. Combinar comparações
entre si, com “e”, “ou” e “não”, pede operadores próprios, que chegam no
capítulo 4 junto das estruturas que decidem.
Promoção e casting
Misturar tipos numa expressão é permitido, com uma regra fixa, e é ela que
conserta o rateio da previsão: na divisão total / 2.0, um lado é int e o
outro é double; antes da conta, o int é convertido para double, a
divisão acontece entre dois double e o resultado é 3.5, com a fração
preservada. Essa
conversão automática do tipo mais estreito para o mais largo da expressão
chama-se promoção, e segue a largura dos tipos: int promove para long,
e ambos promovem para double. O char entra nas contas como o número que
ele é: 'A' + 1 vale 66, um int.
A conversão no sentido contrário nunca é automática; ela existe, mas precisa ser pedida por escrito, com o tipo de destino entre parênteses:
double preco = 3.9;
int inteiro = (int) preco;
char letra = (char) 66;
Esse pedido chama-se casting. (int) 3.9 vale 3: o casting de double para
int descarta a parte fracionária, sem arredondar. (char) 66 vale 'B',
o caminho de volta da tabela Unicode. E um casting de long para int com
valor grande demais corta o que não cabe, em silêncio: o compilador aceita
porque a ordem foi explícita, e a responsabilidade pelo corte passa a ser de
quem escreveu.
Quando a conta estoura
Uma multiplicação inocente:
void main() {
int populacao = 2_000_000_000;
int dobro = populacao * 2;
IO.println(dobro);
}
-294967296
O programa compila, roda e imprime um número negativo. Nenhuma mensagem de erro aparece, em nenhum dos dois momentos.
O nome disso é overflow. Um int vive em 32 bits, e 32 bits comportam
exatamente os valores do intervalo dado na seção dos primitivos; uma conta
que passa do máximo dá a volta e continua a contagem do outro extremo, no
lado negativo. A JVM não trata isso como erro: é o comportamento definido, o
programa segue, e o valor errado se espalha pelas contas seguintes. É a
primeira das duas surpresas prometidas na abertura, e aparece em lugares
previsíveis: contadores que crescem sem parar, quantias em centavos somadas
aos milhões, multiplicações de valores já grandes.
A correção é usar um tipo mais largo, com um detalhe que engana: trocar só o
tipo da variável de destino não basta. long dobro = populacao * 2 imprime
o mesmo valor errado, porque a conta entre dois int acontece em int, e o
estrago já está feito quando o resultado é guardado. populacao * 2L
resolve: com um long na expressão, a promoção sobe a conta inteira para
long antes de multiplicar.
Complemento de dois. Nos 32 bits de um int, o bit mais alto indica o
sinal, e os negativos são representados de um jeito que faz a soma funcionar
com um circuito só, chamado complemento de dois. Somar 1 ao maior positivo
produz o padrão de bits do menor negativo; por isso o estouro “dá a volta”
em vez de parar o programa.
O que o ponto flutuante esconde
Uma soma de uma linha:
void main() {
IO.println(0.1 + 0.2);
}
O que aparece no terminal?
0.30000000000000004
Esta é a segunda surpresa prometida na abertura. Um double guarda o número
em binário, a notação do capítulo 1, e nem todo número decimal tem escrita
binária exata. A notação binária também aceita casas depois da vírgula, cada
uma valendo a metade da anterior: 0,1 em binário é um meio, 0,01 é um
quarto, 0,11 é três quartos. Um décimo está para essa escrita como um terço
está para a decimal: 1/3 em decimal vira 0,333… sem fim, e 1/10 em binário
vira uma sequência periódica sem fim. O que cabe nos 64 bits de um double é a aproximação mais próxima;
as sobras de 0.1 e de 0.2, invisíveis na impressão de cada um, aparecem na
soma. Frações cujo denominador é potência de dois, como 0.5 e 0.25, são
exatas, e por isso 0.5 + 0.25 imprime 0.75 sem ruído.
Duas regras práticas saem daí, e as duas voltam nos exercícios. Primeira:
comparar double com == não é confiável, porque dois caminhos de cálculo
que deveriam dar no mesmo valor podem diferir na última casa da aproximação. Segunda:
dinheiro não se guarda em double; um sistema que soma centavos aproximados
espalha diferenças de um centavo que ninguém consegue rastrear. Como
dinheiro se representa em Java é assunto do capítulo 7, junto do sistema que
vai precisar disso. O lugar do double é a medida física, peso, distância,
temperatura, onde o valor já nasce aproximado por natureza.
var
Quando o valor inicial já deixa o tipo evidente, a palavra var declara a
variável sem repeti-lo:
var total = 7;
var preco = 19.90;
O compilador olha o valor inicial e infere o tipo: total é int, preco
é double. Isso se chama inferência de tipo, e vem com duas regras. A
declaração com var exige o valor inicial na mesma linha, porque é dele que
o tipo sai; e o tipo inferido é fixo dali em diante, exatamente como se
estivesse escrito, de modo que total = 1.5 continua sendo recusado. var
não muda o que a variável é; muda só quanto se digita. Nestes primeiros
capítulos o livro escreve os tipos por extenso, para eles ficarem visíveis;
var volta a aparecer no capítulo 17, quando os nomes de tipo crescem.
Prática
-
Escreva um programa que guarde uma temperatura em graus Celsius num
doublee imprima a conversão para Fahrenheit, calculada como a temperatura vezes 9, dividida por 5, mais 32. Confira com um valor conhecido: 100 °C são 212 °F. -
Guarde duas avaliações inteiras, 7 e 8, e imprima a média. Obtenha primeiro o resultado errado, com divisão inteira; depois conserte usando promoção, sem mudar o tipo das duas variáveis.
-
Parta de um
intvalendo 2.147.483.647, some 1 e imprima. Depois refaça a conta de modo a obter o valor correto, e explique por escrito em que ponto da linha a correção agiu. -
Imprima
0.1 + 0.2e depois0.5 + 0.25. Explique por escrito, apoiando-se na comparação com 1/3 em decimal, por que uma soma sai com ruído e a outra não. -
Imprima
'a', depois'a' + 1, depois(char) ('a' + 1). Descreva o tipo do valor em cada uma das três impressões e o papel do casting na última. -
Escreva o valor 255 como literal decimal, hexadecimal e binário, guarde os três em variáveis e imprima os três, provando que a base muda a escrita e não o valor.
Ficha do capítulo
| Tipo | Bits | Guarda | Literal de exemplo |
|---|---|---|---|
int | 32 | inteiros até ±2,1 bilhões | 42, 2_147_483_647, 0x2A, 0b101010 |
long | 64 | inteiros até cerca de ±9,2 × 10¹⁸ | 8_000_000_000L |
double | 64 | ponto flutuante | 19.90, 0.5 |
boolean | a JVM decide | true ou false | true |
char | 16 | um caractere (código Unicode) | 'A' |
byte, short, float | 8, 16, 32 | versões menores de int e double; raras fora de arquivo e rede |
| Operador | O que faz |
|---|---|
+ - * / % | aritmética; / entre inteiros descarta a fração, % dá o resto |
+= -= *= /= | aplica a conta sobre a própria variável |
++ -- | soma ou subtrai 1, como instrução |
== != < <= > >= | comparações; produzem boolean |
(tipo) | casting: conversão explícita, cortando o que não couber |
| Termo | Definição |
|---|---|
| variável | nome que guarda um valor durante a execução |
| declaração | linha que cria a variável, com tipo e nome |
| atribuição | o =: calcula a direita e guarda no nome da esquerda |
| tipo | a espécie de valor que o nome guarda e as operações válidas sobre ele |
| primitivo | tipo cujo valor é guardado diretamente na variável |
| literal | valor escrito diretamente no código |
| expressão | trecho de código que produz um valor |
| operador | símbolo que combina valores numa expressão |
| promoção | conversão automática para o tipo mais largo da expressão |
| casting | conversão explícita, escrita como (tipo), por conta de quem pede |
| overflow | conta que passa do limite do tipo e dá a volta, sem aviso |
| ponto flutuante | formato binário aproximado do double |
var | declaração cujo tipo o compilador infere do valor inicial |
| inferência de tipo | dedução do tipo pelo compilador a partir do valor inicial |
Fluxo de controle e decomposição
Um programa de cinema, salvo em Ingresso.java, precisa responder se um
ingresso é de meia-entrada:
void main() {
int idade = 16;
if (idade < 18) {
IO.println("Meia-entrada.");
} else {
IO.println("Inteira.");
}
}
$ java Ingresso.java
Meia-entrada.
Todos os programas dos capítulos anteriores executavam as mesmas linhas, na mesma ordem, em toda execução. Este não: uma das duas impressões nunca acontece, e qual delas roda depende de um valor. Escolher o caminho da execução, repetir trechos e dar nome a pedaços do programa são as três habilidades deste capítulo, e com elas os programas deixam de ser listas de ordens para virar comportamento.
As construções que fazem isso têm nome de família: estrutura de controle é
toda construção da linguagem que determina a ordem em que as instruções
executam. Na ausência delas vale o fluxo sequencial, uma instrução após a
outra, de cima para baixo, que foi o regime de todos os programas até aqui.
As estruturas de controle quebram essa sequência de três maneiras: por
seleção, escolhendo um entre dois ou mais caminhos, que é o território do
if e do switch deste capítulo; por repetição, voltando a um trecho
enquanto uma condição valer, que é o território dos laços; e por desvio,
abandonando o fluxo corrente, como fazem o return e as palavras de
controle de laço. Qualquer programa, em qualquer linguagem dessa família, se
descreve com sequência, seleção, repetição e desvio, e o resto do capítulo
apresenta as formas que Java dá a cada uma.
if, else e a condição
if executa um bloco somente quando uma condição vale. Condição é uma
expressão de valor boolean, exatamente o que as comparações produzem:
idade < 18 vale true ou false, e o if consulta esse valor.
O bloco entre chaves logo após o if roda no caso true; o bloco do
else, que é opcional, roda no caso false. Encadear decisões é escrever
else if:
if (nota >= 90) {
IO.println("A");
} else if (nota >= 70) {
IO.println("B");
} else {
IO.println("C");
}
As comparações acontecem de cima para baixo e a primeira condição
verdadeira ganha: uma nota 95 imprime só A, porque o else if nem chega a
ser avaliado. A ordem das faixas, portanto, faz parte da lógica, e
invertê-la muda o programa.
As chaves são opcionais quando o corpo é uma única instrução, e este livro as escreve sempre. O motivo cabe numa armadilha, mais adiante.
Combinando condições
O capítulo 3 deixou uma promessa: os operadores que combinam decisões. São
três. && é o “e”: a expressão inteira só vale true com os dois lados
true. || é o “ou”: basta um lado true. ! é o “não”: inverte o valor
que vem depois.
boolean meia = idade < 18 || idade >= 60;
boolean pagaInteira = !meia;
&& e || avaliam apenas o necessário, e isso tem nome: curto-circuito.
Quando o lado esquerdo já decide o resultado, o lado direito nem é avaliado:
um && com esquerda false já é false, um || com esquerda true já é
true, e a execução segue sem tocar no resto. Não é só economia; é uma
técnica de proteção. A divisão inteira por zero derruba o programa com um
erro de execução chamado ArithmeticException, e o curto-circuito permite
blindar a conta na própria condição:
if (pessoas != 0 && total / pessoas > 100) {
IO.println("Rateio alto.");
}
Com pessoas valendo zero, o lado esquerdo é false, a divisão nunca
acontece e o programa segue. Invertida a ordem dos dois lados, a mesma linha
derruba o programa. Em curto-circuito, a ordem dos operandos é parte da
correção, não do estilo.
Decisão em forma de valor
Duas construções decidem produzindo um valor, em vez de executar blocos. O operador ternário escolhe entre duas expressões:
int desconto = idade < 18 ? 50 : 0;
Lê-se: se a condição vale, o valor é o do meio; senão, o do fim. Ele serve para escolhas curtas dentro de uma atribuição. Encadear ternários dentro de ternários compila, mas obriga o leitor a resolver as condições de cabeça para descobrir qual valor sai; este livro não os encadeia.
Para escolher entre vários casos a partir de um mesmo valor, existe o switch expression (a expressão switch):
int minutosDeTreino = switch (dia) {
case 1, 7 -> 60;
case 6 -> 30;
default -> 45;
};
O valor entre parênteses é comparado com cada case; a seta aponta o valor
produzido; casos podem ser agrupados com vírgula; e default cobre todo o
resto. O switch expression exige que algum caminho exista para qualquer
valor possível, e com um int na entrada isso obriga o default: sem ele,
erro de compilação. Essa exigência é uma proteção, e ela cresce de
importância no capítulo 12, quando a entrada do switch passa a ser um tipo
com lista fechada de valores. Material antigo mostra um switch de outro
formato, com dois-pontos no lugar da seta e uma regra de continuação em que
a execução atravessa também os casos seguintes por omissão; este livro usa
apenas a forma nova.
Laços
Um laço repete um bloco enquanto uma condição valer. O while é a forma
crua:
int restantes = 3;
while (restantes > 0) {
IO.println(restantes);
restantes--;
}
IO.println(0);
3
2
1
0
A condição é conferida antes de cada volta, inclusive a primeira: um
while com condição inicial false não roda nenhuma vez. E uma condição
que nunca vira false não para nunca; o programa fica preso, sem erro e sem
saída, até ser morto no terminal com Ctrl+C.
Quando a repetição tem contador, começo e passo, o for empacota as três
partes na primeira linha:
for (int volta = 1; volta <= 5; volta++) {
IO.println(volta * 7);
}
A primeira parte declara e inicia o contador, a segunda é a condição
conferida antes de cada volta, a terceira roda ao fim de cada volta. O
trecho imprime a tabuada do 7 até 35. Dentro de qualquer laço valem duas
palavras de controle, definidas aqui de passagem: break abandona o laço na
hora, e continue pula direto para a próxima volta.
Um contador fracionário, somando de um décimo em um décimo até chegar a um:
void main() {
double x = 0.0;
while (x != 1.0) {
x += 0.1;
}
IO.println("Cheguei.");
}
O programa compila e roda. O que acontece no terminal?
Nada, para sempre. O capítulo 3 mostrou que 0.1 não tem escrita binária
exata; somado dez vezes, o acumulado passa perto de 1.0 sem valer exatamente
1.0, a comparação != nunca vira false, e o laço não termina. Nenhuma
mensagem aparece, porque nenhum erro aconteceu: o programa está fazendo o
que está escrito. A regra prática: contador de laço é inteiro; o double
entra na conta de dentro, nunca no controle da repetição.
Métodos próprios e decomposição
Até aqui, todo código deste livro mora em main. Um arquivo-fonte compacto
aceita outros métodos ao lado dele:
int precoDoIngresso(int idade) {
if (idade < 18 || idade >= 60) {
return 20;
}
return 40;
}
void main() {
IO.println(precoDoIngresso(16));
IO.println(precoDoIngresso(30));
}
20
40
Três novidades moram aí. int idade, entre os parênteses da declaração, é
um parâmetro: uma variável que nasce a cada chamada, já valendo o argumento
que veio de fora. O argumento é o valor entregue na chamada; o parâmetro é o
nome que o recebe na declaração. A palavra return devolve um valor ao ponto que chamou e encerra o método
naquele instante: na primeira chamada acima, o return 20 roda e o
return 40 nem é alcançado. E o int antes do nome do método declara o
tipo do valor devolvido, ocupando a posição onde main escreve void, a
marca de quem não devolve nada.
O nome disso tudo é decomposição: partir um programa em métodos pequenos, cada um com um trabalho nomeado. O ganho não é estético. Um cálculo que existe num único lugar é corrigido num único lugar, e um método com nome honesto documenta o programa melhor do que comentário.
Cada método tem uma assinatura: o nome mais os tipos dos parâmetros, na ordem. É a assinatura que identifica o método, e por isso dois métodos podem ter o mesmo nome com parâmetros diferentes, o que se chama sobrecarga:
int dobro(int valor) {
return valor * 2;
}
double dobro(double valor) {
return valor * 2;
}
Com os dois métodos dobro acima no arquivo:
void main() {
IO.println(dobro(21));
IO.println(dobro(21.0));
IO.println(dobro('a'));
}
Três linhas de saída. Quais, e qual dobro atende cada chamada?
42
42.0
194
O compilador escolhe pela assinatura que casa com o argumento: 21 é int
e vai para o primeiro método, 21.0 é double e vai para o segundo. A
terceira chamada é o caso interessante: não existe dobro de char, e o
compilador converte o argumento por alargamento, na mesma direção da
promoção: char cabe em int, e cabe também em double. Entre as versões
capazes de receber o valor, vence a de tipo mais estreito: a de int é
chamada com 97, o código de 'a', e devolve 194. A escolha acontece
na compilação, em silêncio, e uma sobrecarga nova pode mudar para onde
chamadas antigas vão. Sobrecarga é ferramenta boa para operações realmente
iguais sobre tipos diferentes, e má para qualquer outra coisa.
Escopo
Uma variável não existe no programa inteiro. Ela nasce na linha da
declaração e morre no fim do bloco onde nasceu, e esse alcance chama-se
escopo. O contador declarado na primeira parte de um for vive só dentro
do laço; a variável declarada dentro de um if vive só ali; um parâmetro
vive só no corpo do seu método. Usar o nome fora do escopo reproduz o
cannot find symbol da primeira semana: para o compilador, fora do bloco o
nome simplesmente não existe.
O escopo é o que torna a decomposição segura. Dois métodos podem declarar variáveis de mesmo nome sem conflito, porque cada nome vive no seu corpo; quem escreve um método não precisa saber que nomes os outros usam. Em programas de um arquivo isso parece pouco; num sistema, é o que permite que partes escritas por pessoas diferentes convivam.
Recursão
Um método pode chamar a si mesmo, e isso se chama recursão:
int somaAte(int n) {
if (n == 1) {
return 1;
}
return n + somaAte(n - 1);
}
somaAte(4) devolve 4 + somaAte(3), que devolve 3 + somaAte(2), e
assim até somaAte(1), que devolve 1 sem se chamar de novo: 10 ao todo. A
linha do n == 1 é o caso base, a saída da repetição, e é a parte que não
pode faltar. Sem caso base, as chamadas se acumulam na pilha de chamadas, a
estrutura em que a JVM guarda as variáveis de cada chamada em andamento, até
o limite dela, e o programa cai com um erro de execução chamado
StackOverflowError; o aprofundamento abaixo detalha o mecanismo. Todo
problema recursivo tem uma versão com laço, e este livro usa laços na maior
parte do tempo.
A pilha de chamadas. A JVM guarda as variáveis de cada chamada de
método em andamento numa estrutura que cresce a cada chamada e encolhe a
cada return, a pilha de chamadas. É ela que dá a cada chamada as suas
próprias variáveis, inclusive nas mil chamadas simultâneas de uma recursão.
Recursão sem caso base enche a pilha até o limite, e o nome do erro,
“estouro de pilha” em inglês, descreve exatamente isso.
Um ponto e vírgula a mais, depois da condição:
void main() {
int saldo = -50;
if (saldo >= 0);
{
IO.println("Saldo disponível.");
}
}
Saldo disponível.
O saldo é negativo e a mensagem sai mesmo assim, sem erro nenhum.
O ; logo após o if é uma instrução vazia, válida, e é ela o bloco do
if. As chaves seguintes formam um bloco solto, que roda sempre. O
compilador não reclama porque nada está errado para ele; o programa apenas
não diz o que parece dizer. O mesmo vale para o while: com x positivo,
while (x > 0); gira em silêncio para sempre, porque o corpo vazio nunca
altera x. É por acidentes dessa família que este livro
escreve chaves mesmo em blocos de uma linha, e o hábito vale a pena desde o
primeiro dia.
Prática
-
Escreva um método que receba um ano e devolva
truepara ano bissexto: divisível por 4 e não por 100, exceto quando divisível por 400. Imprima o resultado para 2024, 2025, 2100 e 2400. -
Com um
forde 1 a 100, imprima cada número, substituindo múltiplos de 3 porFizz, múltiplos de 5 porBuzze múltiplos de ambos porFizzBuzz. Decida a ordem das condições e explique por escrito por que ela importa. -
Escreva duas sobrecargas de um método
maior: uma que compara doisinte uma que compara doisdouble. Depois chamemaior(3, 4.5)e explique por escrito qual versão atendeu e por quê. -
Reproduza o laço infinito do contador
doublee conserte de duas formas diferentes: com contador inteiro e com comparação de ordem no lugar da comparação de igualdade. Escreva qual das duas correções é a melhor e o motivo. -
Escreva um método recursivo que conte de
naté zero, imprimindo cada valor. Retire o caso base, execute, anote o nome do erro e explique por que ele é um erro de execução, e não de compilação. -
Escreva um switch expression que receba o número de um mês e devolva quantos dias ele tem num ano comum, agrupando os meses de mesma duração. Fevereiro fica com 28.
Ficha do capítulo
| Construção | O que faz |
|---|---|
if / else if / else | executa o primeiro bloco cuja condição vale |
cond ? a : b | operador ternário: escolhe um valor pela condição |
switch (v) { case ... -> ... } | switch expression: escolhe um valor entre casos |
while (cond) { } | repete enquanto a condição valer; confere antes de cada volta |
for (início; cond; passo) { } | laço com contador empacotado |
break / continue | abandona o laço / pula para a próxima volta |
return expr; | devolve o valor e encerra o método |
| Termo | Definição |
|---|---|
| estrutura de controle | construção que determina a ordem de execução das instruções; seleção, repetição ou desvio |
| condição | expressão boolean consultada por if, laços e afins |
if | executa um bloco somente com a condição valendo |
| curto-circuito | && e || não avaliam o lado direito quando o esquerdo decide |
| operador ternário | decisão em forma de expressão: cond ? a : b |
| switch expression | escolha entre vários casos, produzindo um valor |
| laço | repetição de um bloco controlada por condição |
| parâmetro | variável do método que recebe o argumento da chamada |
return | devolve um valor e encerra o método no ato |
| assinatura | nome do método mais os tipos dos parâmetros |
| sobrecarga | métodos de mesmo nome com assinaturas diferentes |
| escopo | o trecho do programa em que um nome existe |
| recursão | método que chama a si mesmo, com caso base obrigatório |
Referências, objetos, String e arrays
Nenhum programa deste livro, até aqui, recebeu uma informação sequer de quem o executa. Este recebe:
void main() {
String nome = IO.readln("Qual é o seu nome? ");
IO.println("Bem-vindo, " + nome + ".");
}
$ java Recepcao.java
Qual é o seu nome? Ana
Bem-vindo, Ana.
IO.readln escreve a pergunta, espera a resposta terminar com Enter e
devolve o que foi digitado. O valor devolvido é um texto, e texto em Java
tem tipo: String. A segunda linha monta a saudação com o operador +, que
entre textos tem outro significado, apresentado adiante. Duas linhas, e o
programa passou a depender do que quem o executa digita. O preço é que
String não é como os tipos que vieram antes, e a diferença entre as duas
famílias de tipos é o
assunto que dá nome a este capítulo, além de ser a origem de metade dos
defeitos difíceis de quem começa.
String: o texto como valor
Um literal de String é o texto entre aspas duplas, presente desde o
"Olá, mundo." do capítulo 2. A novidade é que esse valor carrega
comportamento próprio: um String é um objeto, um valor composto que reúne
dados e métodos, e os métodos se chamam com um ponto sobre o valor:
void main() {
String curso = "Java 25";
IO.println(curso.length());
IO.println(curso.toUpperCase());
IO.println(curso.charAt(0));
IO.println(curso.substring(0, 4));
}
7
JAVA 25
J
Java
length() devolve a quantidade de caracteres; toUpperCase() devolve a
versão em maiúsculas; charAt(0) devolve o char da posição zero, porque
posições em Java começam do zero, um fato que volta em força na seção de
arrays; substring(0, 4) devolve o trecho da posição 0 até antes da 4. Os
parênteses vazios de length() são a primeira chamada sem argumento do
livro: chamar sem entregar valor nenhum é permitido, e os parênteses
continuam obrigatórios, porque são eles que fazem a chamada. Nenhum desses
métodos altera o texto original; os que produzem texto devolvem outro
String, e a seção de imutabilidade retoma essa propriedade.
O + entre um String e qualquer outro valor produz um String novo com
os dois emendados, e isso se chama concatenação: "Bem-vindo, " + nome
produziu "Bem-vindo, Ana". Números entram na emenda já convertidos para
texto: "Total: " + 7 é o texto "Total: 7".
Arrays: vários valores sob um nome
Guardar as avaliações de um filme em variáveis nota1, nota2, nota3
para de funcionar na décima nota. Um array guarda uma sequência de valores
do mesmo tipo sob um único nome:
void main() {
int[] notas = { 8, 10, 7 };
IO.println(notas.length);
IO.println(notas[0]);
notas[0] = 9;
IO.println(notas[0]);
}
3
8
9
int[] é o tipo “array de int”, e as chaves com valores são o
inicializador de array, uma escrita que só vale junto da declaração. Cada
posição é lida e gravada pelo índice entre colchetes, contado a partir do
zero: num array de 3 posições, os índices válidos são 0, 1 e 2. length,
sem parênteses, informa o tamanho, fixado na criação e imutável dali em
diante. Um array também nasce sem valores escolhidos, com a palavra new e
o tamanho: new int[10] cria dez posições valendo zero, o valor padrão de
int. A próxima seção mostra o que new faz.
Índice fora da faixa é um dos erros de execução clássicos da linguagem.
Pedir notas[3] ao array de três posições acima produz:
$ java Notas.java
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: Index 3 out of bounds for length 3
at Notas.main(Notas.java:4)
A mensagem de ArrayIndexOutOfBoundsException entrega o índice pedido, o
tamanho real e a linha. Ela costuma nascer de um laço com condição <= onde
deveria ser <, porque o último índice válido é o tamanho menos um.
Percorrer um array combina com o for clássico, e existe uma forma
dedicada a “visitar todos”, o laço for-each, que dispensa o índice quando a
posição não importa:
int soma = 0;
for (int nota : notas) {
soma += nota;
}
Lê-se: para cada nota em notas. A cada volta, a variável recebe o valor
da posição seguinte, do primeiro ao último.
Referências: o que a variável guarda de verdade
Duas variáveis, um inicializador de array, uma alteração:
void main() {
int[] a = { 10, 20, 30 };
int[] b = a;
b[0] = 99;
IO.println(a[0]);
}
A alteração foi feita através de b. O que imprime a leitura através de
a?
99
Uma variável de tipo primitivo guarda o
próprio valor; uma variável de tipo objeto, como String e arrays, não
guarda o objeto: guarda o endereço dele, e esse endereço se chama
referência. Quando int[] b = a executa, o que se copia é o endereço, não o
array: passa a haver um array e dois nomes para ele, e mutação através de um
nome é visível através do outro. O compilador não avisa nada, porque nada
está errado; apenas é assim que referências funcionam.
flowchart LR
a["a"] --> obj["array: 99, 20, 30"]
b["b"] --> obj
Os objetos em si vivem numa região da memória chamada heap, criados pelo
new ou pelo inicializador, e as variáveis vivem na pilha de chamadas; o
que elas guardam de um objeto é só a referência para lá. Cada objeto criado tem
identidade: é ele mesmo, distinto de qualquer outro, mesmo que outro objeto
tenha conteúdo idêntico. Dois arrays criados com { 10, 20, 30 } duas vezes
são dois objetos; a e b acima são dois nomes para um só.
Um método que só deveria calcular:
int menorNota(int[] notas) {
int menor = notas[0];
for (int i = 0; i < notas.length; i++) {
if (notas[i] < menor) {
menor = notas[i];
}
notas[i] = 0; // limpa o rascunho, acreditando ser uma cópia
}
return menor;
}
void main() {
int[] notas = { 8, 9, 7 };
IO.println(menorNota(notas));
IO.println(notas[0]);
}
7
0
O método devolveu o menor valor, mas a segunda impressão mostra o array de
main alterado: a limpeza, escrita na crença de que o parâmetro era uma
cópia, zerou o array original.
Quando o tipo de um parâmetro é um objeto, o que ele recebe é uma cópia da referência, e portanto aponta para o mesmo objeto de quem chamou: o que o método altera, o chamador vê. Não há erro em nenhum momento, e o defeito aparece longe da causa, em qualquer código que use o array depois. A disciplina que evita essa família de bugs é dupla: métodos que calculam não alteram o que recebem, e métodos que alteram dizem isso no nome.
Igualdade: == compara identidade, equals compara conteúdo
Um cofre com senha:
void main() {
String digitada = IO.readln("Senha: ");
if (digitada == "abracadabra") {
IO.println("Cofre aberto.");
} else {
IO.println("Senha errada.");
}
}
$ java Cofre.java
Senha: abracadabra
Senha errada.
A senha digitada está correta, o programa compila, roda e nega.
Entre referências, == pergunta se os dois lados apontam para o mesmo
objeto, a identidade, e não se os conteúdos coincidem. O texto digitado é um
objeto novo, criado na leitura; o literal "abracadabra" é outro; conteúdos
iguais, objetos distintos, == falso. A pergunta certa para conteúdo tem
nome: equals.
if (digitada.equals("abracadabra")) {
A regra de bolso do capítulo: primitivos se comparam com ==; objetos, com
equals. O perigo desta armadilha é ela fingir que funciona: comparando
dois literais iguais no mesmo teste rápido, o == chega a valer true, e o
aprofundamento abaixo explica o porquê; o defeito só aparece com texto vindo
de fora, longe do teste que “provou” que estava certo.
O pool de literais. Literais de String idênticos no código são
guardados uma vez só, numa área chamada pool, e por isso "a" == "a" vale
true: são o mesmo objeto. Texto construído em execução, como o devolvido
por IO.readln, nasce fora do pool. É uma economia de memória da JVM, não
uma regra de igualdade em que se possa apoiar.
Imutabilidade e StringBuilder
Nenhum método de String altera o texto: todos devolvem um String novo, e
o original permanece intacto. Essa propriedade se chama imutabilidade, e é
uma decisão deliberada da linguagem:
String curso = "java";
curso.toUpperCase();
IO.println(curso);
java
A segunda linha não é uma ordem para maiúsculas: é uma expressão cujo
resultado, um String novo, foi jogado fora. Para ficar com o resultado,
guarda-se o valor devolvido: curso = curso.toUpperCase(). A imutabilidade
tem um preço quando o texto cresce aos pedaços: cada + cria um objeto
novo, e mil voltas de concatenação criam mil objetos intermediários. Para
montagem em etapas existe o StringBuilder, um objeto de texto que aceita
alteração:
StringBuilder relatorio = new StringBuilder();
for (int nota : notas) {
relatorio.append(nota);
relatorio.append(" ");
}
IO.println(relatorio);
new StringBuilder() cria o objeto vazio, append acrescenta no fim, e
IO.println o aceita diretamente. Como qualquer objeto vira texto na hora
de imprimir é assunto do capítulo 10. A regra prática: concatenação com +
para emendas pontuais; StringBuilder para montagem dentro de laço.
null e a entrada que vira número
Uma referência pode não apontar para objeto nenhum, e o literal desse estado
é null. Chamar qualquer método através de uma referência nula derruba o
programa com um erro de execução: o NullPointerException. O programa
abaixo, salvo em Cadastro.java, o provoca:
void main() {
String nome = null;
IO.println(nome.length());
}
$ java Cadastro.java
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.length()" because "<local1>" is null
at Cadastro.main(Cadastro.java:3)
A mensagem diz qual chamada falhou e sobre o quê: o <local1> é a variável
nome, cujo nome o compilador descarta na tradução, restando a posição
dela. A linha aponta o local da queda; a causa verdadeira, porém, costuma
estar antes, no ponto que deixou a referência nula. Nos programas deste
livro, null quase não aparece por enquanto; ele volta no capítulo 17,
quando buscas passam a poder terminar sem encontrar nada.
IO.readln devolve sempre String, inclusive quando o usuário digita um
número: "25" é texto, e "25" + 1 é a concatenação "251", não a soma
26. A ponte para a aritmética é Integer.parseInt, que converte o texto num
int:
void main() {
String resposta = IO.readln("Sua idade: ");
int idade = Integer.parseInt(resposta);
IO.println(idade + 1);
}
Texto que não é um número derruba a conversão com um erro de execução
chamado NumberFormatException, cuja mensagem mostra o texto recusado. O
que fazer para o programa sobreviver a uma entrada malformada, em vez de
cair, é assunto do capítulo 13. O nome Integer, e a família de que ele
faz parte, é assunto do capítulo 17; por ora ele é o endereço onde mora a
conversão.
A segunda forma de main
O capítulo 2 prometeu uma forma de main que recebe o que foi digitado no
terminal depois do nome do programa. As peças deste capítulo a destravam:
void main(String[] args) {
int a = Integer.parseInt(args[0]);
int b = Integer.parseInt(args[1]);
IO.println(a + b);
}
$ java Soma.java 2 3
5
args é um array de String com os argumentos da linha de comando, na
ordem digitada: args[0] é "2", args[1] é "3", e args.length diz
quantos vieram. Executado sem argumentos, o programa cai com o
ArrayIndexOutOfBoundsException deste capítulo, pedindo o índice 0 de
um array de tamanho zero; conferir args.length antes de ler é o hábito que
evita a queda. As duas formas de main convivem na linguagem, e a JVM
prefere a que declara o array quando as duas existem no arquivo.
Prática
-
Escreva um programa que pergunte o nome e imprima três linhas: o nome em maiúsculas, a quantidade de caracteres e a primeira letra. Use apenas métodos apresentados neste capítulo.
-
Reproduza a previsão dos dois nomes com um array seu e desfaça o compartilhamento: crie um segundo array do mesmo tamanho com
newe copie os valores com um laço, provando com impressões que a alteração num deles parou de afetar o outro. -
Escreva um método
mediaque receba um array deinte devolva a média comodouble, sem alterar o array recebido, usando a promoção do capítulo 3 para não cair na divisão inteira. Imprima a média de{ 7, 8, 10 }. -
Reproduza a armadilha do cofre com
==, conserte comequalse depois quebre de novo: compare dois literais iguais com==e explique por escrito, citando o pool, por que esse caso engana. -
Com
StringBuildere um laço, monte numa única linha os números de 1 a 10 separados por vírgula, sem vírgula sobrando no fim. -
Escreva um programa que receba dois números pela linha de comando e imprima a soma, e que, executado com menos de dois argumentos, imprima uma instrução de uso em vez de cair. Anote qual erro de execução ele sofria antes da sua proteção.
Ficha do capítulo
| Chamada | O que faz |
|---|---|
texto.length() | quantidade de caracteres |
texto.toUpperCase() | devolve a versão em maiúsculas |
texto.charAt(i) | o char da posição i |
texto.substring(a, b) | o trecho da posição a até antes de b |
a.equals(b) | compara conteúdo, não identidade |
montagem.append(x) | acrescenta ao fim do StringBuilder |
Integer.parseInt(texto) | converte o texto num int |
IO.readln(pergunta) | escreve a pergunta e devolve a linha digitada |
new int[n] | array de n posições com o valor padrão do tipo |
| Termo | Definição |
|---|---|
| objeto | valor composto que reúne dados e métodos, criado durante a execução |
| referência | o endereço de um objeto; é o que a variável de tipo objeto guarda |
new | cria um objeto e devolve a referência para ele |
| heap | região da memória onde os objetos vivem |
| identidade | cada objeto é ele mesmo, distinto até de outro de conteúdo igual |
null | referência que não aponta para objeto nenhum |
NullPointerException | erro de execução ao usar uma referência nula |
String | o tipo do texto; objeto imutável |
equals | comparação de conteúdo entre objetos |
| concatenação | o + entre String e outro valor, produzindo String novo |
| imutabilidade | propriedade do objeto que nunca muda após criado |
StringBuilder | objeto de texto alterável, para montagem em etapas |
Integer.parseInt | converte String em int; recusa texto malformado |
IO.readln | lê uma linha do terminal e a devolve como String |
| array | sequência de tamanho fixo de valores do mesmo tipo |
| índice | posição num array, contada do zero |
ArrayIndexOutOfBoundsException | erro de execução por índice fora da faixa |
| laço for-each | percorre todos os valores sem usar índice |
String[] args | forma de main que recebe os argumentos da linha de comando |
Math e Random
Com decisões, laços e entrada de teclado, um jogo de adivinhação está quase ao alcance: o programa pensa num número, a pessoa chuta, o programa responde se o segredo é maior ou menor. Falta uma peça que nenhum código dos capítulos anteriores produz: um número que ninguém escolheu.
void main() {
Random sorteio = new Random();
IO.println(sorteio.nextInt(1, 101));
IO.println(sorteio.nextInt(1, 101));
}
$ java Sorteio.java
73
12
$ java Sorteio.java
41
89
Duas execuções, quatro valores diferentes. Sob o nome Random, a biblioteca
padrão oferece um gerador de números: new Random() cria um, do mesmo jeito
que new StringBuilder() criou o objeto de texto do capítulo 5, e cada
chamada de nextInt(1, 101) devolve um inteiro de 1 a 100. O que exatamente é o nome
Random, e por que new Random() tem essa forma, é assunto do capítulo 7;
este capítulo é sobre usá-lo bem, e sobre o punhado de contas prontas que
mora sob o nome Math.
Sortear números
nextInt existe em duas formas. Com dois argumentos, nextInt(inicio, fim)
devolve um inteiro do início até antes do fim: nextInt(1, 101) produz de 1
a 100, nunca 101. Com um argumento, nextInt(n) devolve de 0 até antes de
n: nextInt(6) produz de 0 a 5. Nas duas formas o limite superior fica de
fora, no mesmo espírito dos índices de array do capítulo 5, que vão de zero
até o tamanho menos um. Além dos inteiros, nextDouble() devolve um
double de 0 até antes de 1, e nextBoolean() devolve true ou false
em cara ou coroa.
Um dado de seis faces para um jogo de tabuleiro:
void main() {
Random dado = new Random();
for (int volta = 0; volta < 5; volta++) {
IO.println(dado.nextInt(6));
}
}
2
0
5
1
3
O programa compila, roda e imprime cinco lançamentos de aparência razoável. O dado, porém, está viciado de um jeito que nenhuma dessas execuções denuncia.
nextInt(6) produz de 0 a 5: este dado tem uma face zero que não existe e
nunca tira seis. Nenhum erro acontece, os valores parecem plausíveis, e o
defeito só seria flagrado por quem contasse milhares de lançamentos. O
limite de fora é, com Random, a fonte clássica dos erros de off-by-one,
o engano de uma unidade num limite, e a correção tem duas grafias
equivalentes: dado.nextInt(6) + 1 ou
dado.nextInt(1, 7). Na dúvida, a forma de dois argumentos deixa a intenção
escrita: de 1 até antes de 7.
Pseudoaleatório e a semente
Dois geradores, criados com o mesmo argumento 42:
void main() {
Random primeiro = new Random(42);
Random segundo = new Random(42);
IO.println(primeiro.nextInt(1, 101));
IO.println(primeiro.nextInt(1, 101));
IO.println(segundo.nextInt(1, 101));
IO.println(segundo.nextInt(1, 101));
}
Quatro sorteios. Que relação existe entre as duas primeiras linhas e as duas últimas?
31
64
31
64
As duas sequências são idênticas, e idênticas em toda execução, em qualquer
máquina. Os números de Random não são sorteados: são calculados, um a
partir do anterior, numa sequência completamente determinada pelo valor
inicial. Por isso o nome técnico é número pseudoaleatório, e o valor que
determina a sequência inteira chama-se semente. new Random(42) fixa a
semente em 42; new Random(), sem argumento, tira a semente do relógio e de
outras fontes variáveis da máquina, e é isso que faz cada execução parecer
um sorteio novo.
Semente fixa parece derrotar o propósito, e é o contrário: é a ferramenta de trabalho. Um defeito que só aparece com certos valores sorteados é um defeito que some quando se tenta observá-lo; com a semente anotada, a execução inteira se repete à vontade, valor por valor. O mesmo vale para comparar duas versões de um programa sob os mesmos dados. A reprodutibilidade volta ao livro no capítulo 15, quando os programas passam a ser testados automaticamente.
De onde saem os números. Um gerador clássico guarda um valor interno e,
a cada pedido, o transforma com multiplicação, soma e resto, devolvendo um
pedaço do resultado. A sequência é longa o bastante para parecer sorteio,
mas quem conhece o valor interno prevê todos os próximos números. Para
sorteio valendo prêmio ou segurança existe SecureRandom, um gerador da
mesma família de uso cuja saída não se prevê; para jogos, simulações e
testes, Random basta e é mais rápido.
Math: contas prontas
Sob o nome Math a biblioteca padrão reúne dezenas de contas prontas,
chamadas pelo próprio nome, sem new, do mesmo jeito que IO.println. O
porquê dessa diferença em relação a Random é assunto do capítulo 7. Este
livro usa um punhado delas, e declara desde já que não pretende cobrir as
demais; a lista completa vive na documentação oficial.
void main() {
IO.println(Math.max(7, 12));
IO.println(Math.min(7, 12));
IO.println(Math.abs(-15));
IO.println(Math.round(2.5));
}
12
7
15
3
max e min escolhem entre dois valores, abs descarta o sinal, e round
arredonda para o inteiro mais próximo, devolvendo long; nos empates,
arredonda para cima, e por isso 2.5 foi a 3, enquanto Math.round(-2.5)
daria −2. round é o
arredondamento que o casting do capítulo 3 não faz: (int) 2.9 trunca para
2, Math.round(2.9) vai a 3. Na ficha ficam ainda pow e sqrt, para
potência e raiz, à disposição de quem precisar; nenhum exercício deste livro
exige matemática além da aritmética.
Tudo junto: o jogo
As peças dos capítulos 3 a 6 montam o jogo completo da abertura:
void main() {
Random sorteio = new Random();
int segredo = sorteio.nextInt(1, 101);
int chute = 0;
while (chute != segredo) {
chute = Integer.parseInt(IO.readln("Chute um número de 1 a 100: "));
if (chute < segredo) {
IO.println("O segredo é maior.");
} else if (chute > segredo) {
IO.println("O segredo é menor.");
}
}
IO.println("Acertou!");
}
$ java Jogo.java
Chute um número de 1 a 100: 50
O segredo é maior.
Chute um número de 1 a 100: 75
O segredo é menor.
Chute um número de 1 a 100: 62
Acertou!
Quinze linhas: variáveis e conversão do capítulo 3 e do 5, decisão e laço do capítulo 4, leitura do teclado do 5, sorteio deste. É o primeiro programa do livro que ninguém executa duas vezes igual, e a prática abaixo o estica em todas as direções.
Prática
-
Corrija o dado da armadilha e prove a correção com força bruta: lance-o um milhão de vezes num laço, conte num array de contadores quantas vezes cada face saiu e imprima as contagens. Faces com contagens próximas, e nenhuma face zero, encerram a discussão.
-
Simule mil lançamentos de moeda com
nextBooleane imprima quantas caras e quantas coroas saíram. Rode três vezes e observe a variação em torno da metade. -
Acrescente ao jogo um contador de tentativas e imprima-o na vitória. Depois limite a sete tentativas, encerrando com a revelação do segredo quando elas acabarem.
-
Rode o jogo com semente fixa e jogue duas partidas idênticas, provando que o segredo se repete. Explique por escrito por que essa versão é melhor para demonstrar o jogo numa aula e pior para jogar de verdade.
-
Sem usar
Math, escreva um método que devolva o maior de três valoresint, usando apenas o capítulo 4. Depois reescreva em uma linha com duas chamadas deMath.maxe compare a legibilidade das duas versões por escrito.
Ficha do capítulo
| Chamada | O que faz |
|---|---|
new Random() | cria um gerador com semente tirada do relógio |
new Random(semente) | cria um gerador de sequência reprodutível |
nextInt(a, b) | inteiro de a até antes de b |
nextInt(n) | inteiro de 0 até antes de n |
nextDouble() | double de 0 até antes de 1 |
nextBoolean() | true ou false |
Math.max(a, b) / Math.min(a, b) | o maior / o menor de dois valores |
Math.abs(x) | valor sem sinal |
Math.round(x) | inteiro mais próximo, como long |
Math.pow(a, b) / Math.sqrt(x) | potência e raiz quadrada |
| Termo | Definição |
|---|---|
Random | gerador de números pseudoaleatórios; a sequência é determinada pela semente |
| número pseudoaleatório | valor calculado em sequência determinada, com aparência de sorteio |
| semente | valor inicial que determina toda a sequência do gerador |
Math | conjunto de contas prontas da biblioteca padrão, chamadas pelo nome |
Classes, construtores e encapsulamento
O mercadinho prometido no prefácio abre as portas neste capítulo, e a primeira tentativa de registrar o estoque dele usa o que o livro tem até aqui:
void main() {
String[] nomes = { "Arroz 5kg", "Café 500g", "Sabão em pó" };
int[] estoques = { 40, 25, 12 };
IO.println(nomes[1] + ": " + estoques[1] + " unidades");
}
$ java Estoque.java
Café 500g: 25 unidades
Funciona, e é uma armadilha armada. Os dados de um produto estão espalhados em dois arrays que só se correspondem pela posição: o café é o nome de índice 1 e o estoque de índice 1, e nada além de disciplina mantém esse acordo. Basta remover um produto de um array e esquecer o outro, ou ordenar os nomes sem ordenar os estoques, para o café passar a exibir o estoque do sabão, sem erro de compilação e sem erro de execução. A raiz do problema é que “produto” não existe no programa; existem pedaços de produto em lugares que o compilador não sabe que se relacionam. Este capítulo cria tipos próprios, e com eles o mercadinho começa a virar sistema.
Um tipo novo: a palavra-chave class
A palavra-chave class declara uma classe: a definição de um tipo de
objeto, com os dados que cada objeto carrega e os métodos que operam sobre
esses dados. Os dados declarados na classe chamam-se campos:
class Produto {
String nome;
int estoque;
}
void main() {
Produto cafe = new Produto();
cafe.nome = "Café 500g";
cafe.estoque = 25;
IO.println(cafe.nome + ": " + cafe.estoque + " unidades");
}
O capítulo 5 apresentou objetos prontos, como String e StringBuilder;
aqui, pela primeira vez, o tipo é nosso. Cada new Produto() cria uma
instância: um objeto independente desse tipo, com os próprios valores nos
campos. Duas instâncias de Produto são dois objetos no heap, cada um com
seu nome e seu estoque, e a variável cafe guarda a referência de um
deles. Nome, estoque e o que mais o produto
tiver agora viajam juntos, e o desalinhamento dos arrays paralelos deixa de
ser possível: não há mais duas listas para dessincronizar.
Construtor e this
Criar o objeto vazio e preencher campo a campo, como acima, deixa um intervalo
perigoso: entre o new e a última atribuição existe um produto pela
metade, visível a qualquer código que rode no meio. O construtor fecha esse
intervalo. Construtor é o método especial que roda no
new, tem o mesmo nome da classe, não declara tipo de retorno e recebe o
necessário para o objeto nascer completo:
Um construtor escrito às pressas:
class Produto {
String nome;
int estoque;
Produto(String nome, int estoque) {
nome = nome;
estoque = estoque;
}
}
void main() {
Produto cafe = new Produto("Café 500g", 25);
IO.println(cafe.nome);
}
O programa compila. O que a impressão mostra?
null
Dentro do construtor existem dois nome: o parâmetro e o campo. Quando
dois escopos sobrepostos declaram o mesmo nome, o mais interno vence, uma
regra nova deste capítulo chamada sombreamento, e nome = nome fala do
parâmetro duas vezes, atribuindo-o a ele mesmo. O campo nunca é tocado e
fica com o valor padrão: campo de objeto nasce valendo zero, false ou
null, conforme o tipo, ao contrário da variável local, que o compilador
recusa usar sem valor. Nenhum aviso, porque a linha é válida. A palavra
this resolve: ela é a referência da própria instância em construção, e
this.nome aponta sem ambiguidade para o campo:
Produto(String nome, int estoque) {
this.nome = nome;
this.estoque = estoque;
}
Invariantes e a recusa no nascimento
Um produto de estoque negativo não descreve coisa nenhuma do mercadinho; se um objeto assim circular pelo sistema, cada relatório que o somar sai errado. A condição que todo objeto válido de um tipo sustenta, do nascimento em diante, chama-se invariante, e o construtor é o lugar de defendê-la:
Produto(String nome, int estoque) {
if (nome == null || nome.isBlank()) {
throw new IllegalArgumentException("Produto precisa de nome.");
}
if (estoque < 0) {
throw new IllegalArgumentException("Estoque não pode ser negativo: " + estoque);
}
this.nome = nome;
this.estoque = estoque;
}
A palavra throw dispara um erro de execução no ponto do problema, e
IllegalArgumentException é o erro padrão da biblioteca para argumento que
viola as regras de quem recebe; a mensagem entre parênteses aparece na tela
com a linha do disparo. O ganho é de localização: sem a defesa, o estoque
negativo entra sem aviso e o efeito aparece longe, num relatório qualquer;
com ela, o
programa cai no instante e na linha em que o valor ruim tentou entrar, com o
valor impresso. O mecanismo completo por trás do throw, incluindo como um
programa reage a ele em vez de cair, é o capítulo 13; isBlank, usado ali,
apenas pergunta se o texto está vazio ou só com espaços.
Encapsulamento
A defesa do construtor tem um furo: qualquer código pode escrever
cafe.estoque = -8 depois do nascimento, por engano ou por atalho. A
solução é controlar o acesso. Os modificadores de acesso definem quem
enxerga cada membro da classe, o nome coletivo de campos, métodos e
construtores: private restringe à própria classe,
public libera para todo o programa. Campo fica private; o que o resto do
programa pode fazer vira método public, e passa pela regra:
class Produto {
private final String nome;
private int estoque;
Produto(String nome, int estoque) {
// validações do construtor, como antes
this.nome = nome;
this.estoque = estoque;
}
public String nome() {
return nome;
}
public int estoque() {
return estoque;
}
public void baixar(int quantidade) {
if (quantidade <= 0 || quantidade > estoque) {
throw new IllegalArgumentException("Baixa inválida: " + quantidade);
}
estoque -= quantidade;
}
}
Isso é encapsulamento: esconder a representação interna de um objeto e
expor apenas operações que mantêm as invariantes. O estoque continua
existindo, mas o único caminho até ele agora valida cada baixa; o
cafe.estoque = -8 passa a ser recusado pelo compilador em qualquer classe
de arquivo próprio, porque o campo é invisível fora de Produto. Uma
ressalva de laboratório: no arquivo-fonte compacto, em que tudo divide um
arquivo, as classes dali de dentro se enxergam por inteiro, private
incluído, e a recusa só vale com cada classe no seu arquivo, que é o
formato da última seção e de todo o livro daqui em diante. O final no campo nome acrescenta outra trava, do próprio
compilador: o modificador final marca o que recebe valor uma vez e nunca
mais, e produto que muda de nome não existe neste domínio. A regra prática
do capítulo: campo private sempre; public é decisão, tomada método a
método, e cada método public é uma promessa de comportamento que o resto
do sistema vai usar.
Dinheiro entra no mercadinho
Falta o preço, e o capítulo 3 deixou dito que double não serve: centavos
aproximados espalham diferenças que ninguém rastreia. Existem duas soluções
honestas. A primeira é guardar centavos em inteiros: int precoEmCentavos,
com 1990 valendo R$ 19,90. É exata, rápida, muito usada em sistemas de
pagamento, e o nome do campo carrega a unidade para ninguém somar centavos
com reais. A segunda é o BigDecimal, o tipo da biblioteca padrão para
números decimais exatos, e é a escolha deste livro, por dois motivos: os
valores aparecem no código como aparecem na etiqueta, e o tipo é um objeto
imutável de método em método, o que exercita exatamente o que este capítulo
ensina.
BigDecimal preco = new BigDecimal("19.90");
BigDecimal total = preco.multiply(new BigDecimal(3));
IO.println(total);
59.70
BigDecimal nasce de new, como qualquer objeto, e o argumento do
construtor é um String com o valor exato. As contas são métodos: add
soma, subtract subtrai, multiply multiplica, e todos devolvem um
BigDecimal novo, porque o tipo é imutável como o String;
comparações de ordem usam o método compareTo, que devolve negativo, zero
ou positivo. No arquivo compacto, BigDecimal resolve sem linha extra; o
import que a moldura exige aparece na última seção.
O construtor de BigDecimal também aceita um double:
BigDecimal errado = new BigDecimal(0.1);
BigDecimal certo = new BigDecimal("0.1");
IO.println(errado);
IO.println(certo);
0.1000000000000000055511151231257827021181583404541015625
0.1
O new BigDecimal(0.1) não criou o valor um décimo: criou o retrato exato
da aproximação binária do double, com todas as casas. O
double já chega contaminado, e o BigDecimal apenas fotografa. Em código
de dinheiro, BigDecimal nasce de String ou de inteiros, nunca de
double; essa regra é curta e a violação passa em qualquer teste que não
imprima as casas todas.
static: o membro que pertence ao tipo
Dois capítulos deixaram uma diferença sem explicação: Random exige
new Random(), e Math.max se chama direto pelo nome. A resposta é o
modificador static. Um membro estático pertence à classe, não a cada
instância: existe uma única cópia, acessada pelo nome do tipo, sem new.
Math é uma classe cujos métodos são todos estáticos, porque max e abs
não dependem de nenhum estado guardado; Random precisa de instância porque
cada gerador carrega o próprio estado, a semente em andamento. IO.println
e Integer.parseInt seguem o mesmo desenho de Math. Em Produto, o uso
típico de static é a constante compartilhada por todas as instâncias:
static final int ESTOQUE_MAXIMO = 10_000;
static final com nome em maiúsculas é a grafia consagrada de constante. A
regra prática: método estático para cálculo que não depende de instância;
todo o resto, membro de instância. Estado mutável em campo estático é uma
única variável compartilhada pelo programa inteiro, e os defeitos disso
aparecem no capítulo 22.
A moldura, enfim
O capítulo 2 prometeu explicar as linhas que o arquivo-fonte compacto dispensa. Todas as palavras delas agora têm dono:
import java.math.BigDecimal;
public class Loja {
public static void main(String[] args) {
Produto cafe = new Produto("Café 500g", 25);
IO.println(cafe.nome());
}
}
public class Loja declara a classe visível a todo o programa, no arquivo
Loja.java, com o nome do arquivo amarrado ao da classe pública; é dessa
declaração que a extensão .class do bytecode tira o nome. O main com public static é a forma
tradicional, esperada por décadas de ferramentas e presente em praticamente
todo projeto existente: static dispensa instância para a partida, e
public o expõe ao lançador. O Java 25 flexibilizou esse protocolo junto
com o arquivo compacto, e formas mais enxutas de main também valem; o
livro escreve a tradicional na moldura, porque é a que o leitor vai
encontrar. O import declara de
onde vem um tipo de fora do arquivo: BigDecimal mora no pacote
java.math, e pacote é o espaço de nomes que agrupa classes relacionadas,
declarado com package na primeira linha de um arquivo e refletido em
pastas homônimas no disco. Os capítulos até o 13 mantêm as classes sem
declaração de pacote, para javac *.java e java Loja continuarem diretos;
a organização completa em pastas chega com a ferramenta do capítulo 14.
A moldura que o compilador escrevia. No arquivo-fonte compacto, o
compilador gera uma classe implícita em volta dos métodos soltos e importa
de uma vez os tipos principais da biblioteca padrão; é por isso que
IO.println e Random sempre funcionaram sem import. A moldura escrita
não acrescenta poder nenhum: torna explícito o que era fornecido, e passa a
ser necessária quando o programa tem mais de uma classe pública em arquivos
próprios.
Prática
-
Escreva a classe
Produtocompleta deste capítulo, com nome, estoque e preço emBigDecimal, invariantes no construtor e camposprivate. Monte na estrutura da última seção, cada classe em seu arquivo, tente violar cada invariante a partir domaine anote o que o compilador recusa e o que o construtor recusa. -
Acrescente a
Produtoum métodorepor(int quantidade)com a validação que ele merece, e um métodovalorEmEstoque()que devolva o preço multiplicado pelo estoque, emBigDecimal. -
Reproduza a armadilha do
new BigDecimal(0.1)e conserte. Depois some0.10dez vezes comBigDecimale imprima, comparando com a soma de0.1dez vezes emdoubledo capítulo 3. -
Escreva uma classe
Caixacom um campoprivate BigDecimalpara o total do dia e um métodoregistrar(Produto produto, int quantidade)que baixa o estoque e acumula o valor da venda. Imprima o total após três vendas. -
Refaça o programa de abertura, com os arrays paralelos substituídos por um array de
Produto, e escreva em um parágrafo qual classe de erro ficou impossível na nova versão. -
Converta o jogo de adivinhação do capítulo 6 para a moldura completa: uma classe pública com
main, em arquivo próprio, compilada comjavace executada comjava.
Ficha do capítulo
| Termo | Definição |
|---|---|
classe (class) | declaração de um tipo de objeto: os campos e os métodos dele |
| campo | dado declarado na classe, presente em cada objeto |
| instância | um objeto de uma classe, criado por new |
| construtor | método de nome igual ao da classe, executado pelo new; sem tipo de retorno |
this | a referência da própria instância |
| invariante | condição que todo objeto válido do tipo sustenta sempre |
throw | dispara um erro de execução no ponto do problema |
IllegalArgumentException | erro padrão para argumento que viola as regras de quem recebe |
| modificador de acesso | public (visível a todos) e private (só a própria classe) |
| encapsulamento | esconder a representação e expor operações que mantêm as invariantes |
modificador final | campo que recebe valor uma vez e nunca mais |
static / membro estático | membro que pertence à classe, único, acessado pelo nome do tipo |
| representação de dinheiro | centavos em inteiros, ou BigDecimal; nunca double |
BigDecimal | decimal exato e imutável; nasce de String, opera por métodos |
| pacote | espaço de nomes que agrupa classes; declarado com package |
import | declara de onde vem um tipo usado no arquivo |
Herança, polimorfismo e composição
O mercadinho vende café em pacote fechado e queijo fatiado na hora: um tem
preço por unidade, o outro por quilo. Com o capítulo 7, a saída natural
seria uma segunda classe, ProdutoPorPeso, copiando de Produto o nome, as
validações e tudo o mais, e mudando só o cálculo do preço. A cópia funciona
no primeiro dia e começa a cobrar no segundo: cada correção em uma classe precisa
ser lembrada na outra, e a que for esquecida diverge em silêncio. A
linguagem tem um mecanismo para “é igual àquela, exceto em”: a herança.
class Produto {
private final String nome;
private final BigDecimal preco;
Produto(String nome, BigDecimal preco) {
// validações do capítulo 7
this.nome = nome;
this.preco = preco;
}
public String nome() {
return nome;
}
public BigDecimal preco() {
return preco;
}
public BigDecimal precoPara(int quantidade) {
return preco.multiply(new BigDecimal(quantidade));
}
public String etiqueta() {
return nome + ", R$ " + preco;
}
}
class ProdutoPorPeso extends Produto {
ProdutoPorPeso(String nome, BigDecimal precoPorQuilo) {
super(nome, precoPorQuilo);
}
@Override
public String etiqueta() {
return super.etiqueta() + " o quilo";
}
}
O estoque do capítulo 7 sai de cena nestes trechos, para cada exemplo mostrar só o que muda; na classe do projeto ele continua onde estava, com as validações.
extends, superclasse e subclasse
Herança é o mecanismo em que uma classe estende outra, recebendo os campos e
os métodos dela e podendo acrescentar os seus ou redefinir os herdados. A
palavra extends declara a relação: Produto é a superclasse,
ProdutoPorPeso é a subclasse. Uma instância de ProdutoPorPeso é um
Produto no sentido pleno: carrega os campos da superclasse e responde a
todos os métodos dela.
O que se herda tem fronteiras precisas, e três delas evitam surpresa.
Primeira: construtores não são herdados. Cada classe declara os seus, e o
construtor da subclasse abre com super(...), a chamada ao construtor da
superclasse, obrigatória antes de qualquer outra coisa; é por ela que as
validações do capítulo 7 continuam protegendo o nascimento, e um
ProdutoPorPeso de nome vazio é recusado pela mesma linha que recusa um
Produto de nome vazio. Segunda: campo private da superclasse existe
dentro do objeto da subclasse, mas continua invisível até para ela; quem o
acessa são os métodos da própria superclasse, e a subclasse os usa como
qualquer outro código usa. Terceira: a herança pode ser proibida. O
modificador final, aplicado a uma classe, veta o extends
(String é uma classe final, e é em parte por isso que a imutabilidade
dela é uma garantia, não um costume); aplicado a um método, veta a
sobrescrita daquele método, deixando o resto da classe aberto. Uma classe
que não foi desenhada para ser estendida declara isso com final, e o
compilador passa a defender a decisão.
Sobrescrita e @Override
Redefinir na subclasse um método herdado chama-se sobrescrita, e é o que
etiqueta() fez: a versão de ProdutoPorPeso substitui a herdada, podendo
aproveitá-la por dentro com super.etiqueta(), a chamada da versão da
superclasse. A linha @Override em cima do método é uma anotação: uma marca
no código, lida pelo compilador e por ferramentas, que não muda o
comportamento. Essa anotação específica pede ao compilador que confira se o
método de fato sobrescreve algo, e a armadilha abaixo mostra o que acontece
sem essa conferência.
O queijo custa R$ 39,80 o quilo, e a subclasse calcula o preço por gramas:
class ProdutoPorPeso extends Produto {
// construtor como antes
public BigDecimal precoPara(long gramas) {
return preco().multiply(new BigDecimal(gramas))
.divide(new BigDecimal(1000));
}
}
void main() {
Produto queijo = new ProdutoPorPeso("Queijo minas", new BigDecimal("39.80"));
IO.println(queijo.precoPara(250));
}
9950.00
Duzentos e cinquenta gramas de queijo saíram por nove mil, novecentos e cinquenta reais: o programa cobrou 250 unidades de R$ 39,80.
O método novo recebe long; o herdado recebe int. Assinaturas diferentes
não sobrescrevem: convivem, como sobrecarga. Pela variável queijo, de tipo
Produto, a versão de long nem é visível, e a chamada vai direto à
herdada, que multiplica por unidade; mesmo por uma variável do subtipo, o
argumento 250, um int, prefere a versão herdada de int, pela regra do
tipo mais estreito do capítulo 4. Nenhum caminho comum chega ao método
novo, nenhum erro aparece, e a diferença só existe na conta do cliente.
Com @Override escrito sobre o método, o compilador teria recusado na hora,
avisando que precoPara(long) não sobrescreve nada. Daí a regra, curta:
toda sobrescrita carrega @Override, sempre, porque a anotação transforma
esse engano silencioso em erro de compilação.
Polimorfismo e despacho dinâmico
A herança paga o preço da hierarquia quando o código trata tudo pelo tipo da superclasse:
Um carrinho misto:
void main() {
Produto[] carrinho = {
new Produto("Café 500g", new BigDecimal("19.90")),
new ProdutoPorPeso("Queijo minas", new BigDecimal("39.80"))
};
for (Produto item : carrinho) {
IO.println(item.etiqueta());
}
}
A variável do laço tem tipo Produto nas duas voltas. Qual etiqueta()
roda para o queijo?
Café 500g, R$ 19.90
Queijo minas, R$ 39.80 o quilo
A do queijo, com “o quilo” no fim. Isso é polimorfismo: referências do tipo da superclasse apontando para objetos de subtipos variados, com cada chamada de método atendida pela versão do objeto real, não pela do tipo da variável. A escolha acontece durante a execução, objeto a objeto, e tem nome: despacho dinâmico. É o inverso da sobrecarga do capítulo 4: a sobrecarga é resolvida pelo compilador, olhando o tipo escrito do argumento; a sobrescrita é resolvida pela JVM, na hora, olhando o objeto real. Os dois mecanismos convivem na mesma chamada, cada um decidindo a sua metade.
Para o mercadinho, a consequência prática vale ser dita por inteiro. O laço
do carrinho foi escrito conhecendo só Produto, e continua correto para
subtipos que ainda não existem: no dia em que o mercadinho passar a vender
produto com desconto de validade próxima, a classe nova entra, sobrescreve o
cálculo, e o caixa, o carrinho e os relatórios somam certo sem uma linha
editada. Código que ganha comportamento novo sem ser tocado é o que o
polimorfismo compra, e é a diferença entre acrescentar uma classe e caçar
todos os if do sistema.
Object, casts e instanceof
Toda classe sem extends estende Object, a superclasse de todas:
Produto estende Object sem que ninguém tenha pedido, e a cadeia
completa do queijo é Object, depois Produto, depois ProdutoPorPeso. Duas consequências saem desse desenho. A
primeira: uma variável do tipo Object aceita referência para qualquer
objeto, o que dá à biblioteca padrão um jeito de escrever código que serve
para todos os tipos de uma vez; o custo e o conserto desse truque são o
assunto do capítulo 16. A segunda: Object declara métodos que toda classe
herda, entre eles o equals conhecido do capítulo 5, e o capítulo 10 é
inteiro sobre o que esses métodos prometem e como sobrescrevê-los bem.
Guardar um ProdutoPorPeso numa variável Produto, como o carrinho fez, é
um upcast: a conversão implícita para o tipo mais geral, sempre segura,
porque a subclasse responde a tudo que a superclasse promete. O caminho de
volta é o downcast, escrito como o casting do capítulo 3, e ele é uma
aposta:
Produto item = carrinho[1];
ProdutoPorPeso porPeso = (ProdutoPorPeso) item;
Se o objeto real não for um ProdutoPorPeso, a linha derruba o programa com
um erro de execução chamado ClassCastException, na hora do cast. A
pergunta que evita a queda é o instanceof, que responde true quando o
objeto real é do tipo perguntado:
if (item instanceof ProdutoPorPeso) {
ProdutoPorPeso porPeso = (ProdutoPorPeso) item;
// trato especial
}
Vale registrar o sinal de alerta: um código que enfileira instanceof para tratar
cada subtipo de um jeito está refazendo à mão o que o despacho dinâmico faz
sozinho, e cada subtipo novo exige lembrar de mais um if. Quando o
comportamento varia por tipo, o lugar dele é um método sobrescrito; o
capítulo 9 dá a essa ideia a forma definitiva.
Composição e delegação
Nem toda relação entre classes é “é um”. O carrinho de compras do mercadinho tem produtos, e tem-um se modela guardando referências, o que se chama composição:
class Carrinho {
private final Produto[] itens;
private final int[] quantidades;
// construtor e validações omitidos
public BigDecimal total() {
BigDecimal soma = BigDecimal.ZERO;
for (int i = 0; i < itens.length; i++) {
soma = soma.add(itens[i].precoPara(quantidades[i]));
}
return soma;
}
}
Carrinho não estende coisa nenhuma: contém. E o total() não calcula
preço de produto; pede a cada item que calcule o seu, via precoPara, e
soma. Encaminhar trabalho para um objeto contido chama-se delegação, e o
polimorfismo continua valendo dentro dela: o item por peso responde com a
conta por peso. Os dois arrays lado a lado reabrem, de propósito, a dívida
da abertura do capítulo 7: aqui dentro, escondidos pelo encapsulamento e
mantidos por uma classe só, eles têm um dono; ainda assim são dívida, e o
capítulo 11 dá ao par produto-quantidade a forma definitiva. BigDecimal.ZERO, de passagem, é uma constante
static final da própria classe, irmã das constantes da seção de
static.
A escolha entre herança e composição tem uma regra que envelheceu bem:
herança só quando cada subclasse é um caso genuíno da superclasse, no
domínio, e o resto é composição. Herança amarra forte: tudo que a
superclasse muda, as subclasses sentem. Composição depende só do essencial:
o Carrinho conhece apenas as promessas públicas de Produto. Na dúvida entre as
duas, composição, e o arrependimento é menor.
Como a JVM despacha. Cada classe carregada tem uma tabela com o endereço da versão de cada método que vale para ela; sobrescrever um método troca a entrada na tabela da subclasse. Uma chamada polimórfica consulta a tabela do objeto real, e é por isso que o despacho custa quase nada e não depende de quantos subtipos existem.
Prática
-
Implemente
ProdutoeProdutoPorPesocompletos, com o preço por gramas correto na subclasse,@Overrideem toda sobrescrita e as validações do capítulo 7. Imprima a etiqueta e o preço de 250 gramas de um queijo de R$ 39,80 o quilo. -
Reproduza a armadilha da sobrecarga acidental: remova o
@Override, troque o tipo do parâmetro e documente a conta errada. Devolva o@Overridee anote a mensagem exata do compilador. -
Acrescente uma terceira subclasse,
ProdutoComDesconto, que sobrescrevaprecoParaaplicando um percentual de desconto recebido no construtor, com validação do percentual. Ponha os três tipos num mesmo array e some o carrinho sem nenhuminstanceof. -
Escreva um trecho que provoque
ClassCastExceptionde propósito, depois o conserte cominstanceof, e por fim explique por escrito por que a versão do exercício 3 é melhor que as duas. -
Modele com composição um
Combode café da manhã: um nome próprio e um conjunto de produtos, comtotal()delegando o preço a cada um. Escreva em um parágrafo por queCombo extends Produtoseria uma modelagem pior.
Ficha do capítulo
| Termo | Definição |
|---|---|
herança (extends) | uma classe estende outra, herdando campos e métodos |
| superclasse / subclasse | a classe estendida / a classe que estende |
super | chamada ao construtor ou ao método da superclasse |
| sobrescrita | redefinição, na subclasse, de um método herdado; mesma assinatura |
| anotação | marca no código lida por ferramentas; @Override confere a sobrescrita |
| polimorfismo | referências do tipo base atendidas pela versão do objeto real |
| despacho dinâmico | a escolha do método pelo objeto, em execução |
Object | superclasse de todas as classes |
| upcast | conversão implícita para o tipo mais geral; sempre segura |
| downcast | cast para o subtipo; sujeito a ClassCastException |
instanceof | pergunta se o objeto real é do tipo dado |
ClassCastException | erro de execução de um downcast errado |
| composição | objeto que contém referências a outros (“tem-um”) |
| delegação | encaminhar trabalho ao objeto contido |
Interfaces e abstração
O caixa do mercadinho aceita dinheiro, cartão e fiado, e cada forma altera o
valor final da compra de um jeito: no dinheiro o mercadinho dá 5% de
desconto, no cartão repassa a taxa da máquina, no fiado o valor apenas vai
para a caderneta. A herança do capítulo 8 não modela isso bem, e vale tentar para ver a
recusa. Cartão não é um caso de dinheiro, então Cartao extends Dinheiro
mente sobre o domínio e herda um desconto que não existe no cartão. Uma
superclasse Pagamento com o cálculo dentro também não fecha, porque não há
cálculo comum para herdar: cada forma tem a própria aritmética e nenhum
campo compartilhado. A herança serve quando há implementação comum e
parentesco genuíno; aqui não há nem um nem outro. O que os três compartilham
é só uma promessa: dado o valor da compra, cada um sabe dizer o valor
final. Promessa sem implementação comum tem uma
construção própria na linguagem:
interface MeioDePagamento {
BigDecimal valorFinal(BigDecimal compra);
}
class Dinheiro implements MeioDePagamento {
@Override
public BigDecimal valorFinal(BigDecimal compra) {
return compra.multiply(new BigDecimal("0.95"));
}
}
class Cartao implements MeioDePagamento {
@Override
public BigDecimal valorFinal(BigDecimal compra) {
return compra.multiply(new BigDecimal("1.02"));
}
}
interface, implements e o contrato
Uma interface declara um conjunto de métodos que um tipo promete oferecer,
sem dizer como: só as assinaturas e os tipos de retorno, sem corpo. Os
métodos de uma interface são public por definição, e uma classe adere à
promessa com implements, ficando obrigada pelo compilador a dar corpo a
cada método declarado; esquecer um é erro de compilação, não descuido
possível. Ao contrário do extends, que aceita uma superclasse só, uma
classe pode implementar quantas interfaces fizerem sentido: duas promessas
de mesma assinatura pedem a mesma coisa, e a ressalva dos métodos com corpo
aparece na seção do default. Cada interface é um papel, e
um mesmo tipo pode exercer vários: o ProdutoPorPeso do capítulo 8 pode
implementar um contrato Pesavel sem deixar de ser um Produto, e cada
trecho do sistema o enxerga pelo papel que lhe interessa.
Uma interface também é um tipo, com tudo que isso implica desde o capítulo
3: existe variável do tipo MeioDePagamento, parâmetro, retorno e array
desse tipo, e guardar um Dinheiro numa variável assim é um upcast,
sempre seguro. A única coisa que não existe é
new MeioDePagamento(), porque não há implementação para instanciar; toda
referência de tipo interface aponta para um objeto de alguma classe
concreta.
A palavra para o que a interface captura é contrato: a promessa observável
de um tipo, o que se pode chamar e o que cada chamada garante, separada de
qualquer detalhe de como se cumpre. MeioDePagamento é um contrato de uma
cláusula: entra o valor da compra, sai o valor final. Dinheiro e Cartao
são dois cumpridores com aritméticas próprias, e o resto do sistema não tem
por que saber qual é qual.
O contrato obriga os dois lados. Quem chama só pode contar com o que a
interface promete, e nada do que souber por fora; quem implementa deve
honrar a promessa por inteiro, inclusive as partes que o compilador não
confere. O compilador garante a assinatura, mas uma implementação que
devolvesse null como valor final estaria tecnicamente compilando e
concretamente quebrando o contrato, e derrubaria o caixa com um
NullPointerException longe da causa. Contrato bem escrito
diz também essas cláusulas de comportamento, nem que seja em comentário
sobre a interface, e o capítulo 15 mostra como transformá-las em verificação
executável.
Programar contra o contrato
O ganho aparece no caixa:
class Caixa {
public BigDecimal fechar(BigDecimal compra, MeioDePagamento meio) {
return meio.valorFinal(compra);
}
}
O parâmetro é do tipo da interface, e o polimorfismo vale igual para ela: a chamada meio.valorFinal(compra) é despachada para a
classe do objeto real. O Caixa compila sem conhecer Dinheiro nem
Cartao, e é essa ignorância deliberada que tem nome: acoplamento é o grau
em que um trecho de código depende dos detalhes de outro, e programar contra
a interface deixa o acoplamento no mínimo necessário. Quando o mercadinho
adotar Pix, a classe nova implementa MeioDePagamento e o Caixa a atende
sem uma linha editada, no mesmo espírito do carrinho de compras.
O caminho oposto é o sinal de alerta registrado no capítulo 8, e vale ver o custo dele por extenso:
Um caixa escrito antes das interfaces, decidindo por tipo:
public BigDecimal fechar(BigDecimal compra, Object meio) {
if (meio instanceof Cartao) {
return compra.multiply(new BigDecimal("1.02"));
}
return compra.multiply(new BigDecimal("0.95"));
}
O mercadinho adota Pix e alguém escreve a classe Pix. O programa compila e
todas as vendas seguem passando. O que acontece com uma compra de R$ 100,00
paga em Pix?
Sai por R$ 95,00. O Pix não casa com o instanceof de Cartao, escorrega
para o ramo final, e leva o desconto que era do dinheiro. Nenhum erro em
nenhum momento: o prejuízo de 5% em cada venda no Pix aparece no fechamento
do mês, longe da causa. A cascata de tipos exige que cada tipo novo lembre
de se apresentar em cada cascata do sistema; o contrato inverte a
obrigação, porque quem chega já traz o próprio comportamento. É a mesma
lição do polimorfismo, agora sem exigir parentesco de classe nenhum. O
fiado, terceiro meio da abertura, é o cumpridor mais simples do contrato,
devolvendo a própria compra, e o registro na caderneta, com o limite dele,
é assunto que o capítulo 13 retoma:
class Fiado implements MeioDePagamento {
@Override
public BigDecimal valorFinal(BigDecimal compra) {
return compra;
}
}
Método default
Contrato publicado é compromisso: no dia em que MeioDePagamento ganhar um
método novo, toda classe que o implementa quebra de uma vez, com um erro de
compilação por implementador. Para evoluir sem quebrar, uma interface pode
dar corpo padrão a um método, com a palavra default:
interface MeioDePagamento {
BigDecimal valorFinal(BigDecimal compra);
default String nomeNoRecibo() {
return "Pagamento";
}
}
class Pix implements MeioDePagamento {
@Override
public BigDecimal valorFinal(BigDecimal compra) {
return compra;
}
@Override
public String nomeNoRecibo() {
return "Pix";
}
}
Dinheiro e Cartao continuam como estavam, sem saber que nomeNoRecibo
existe. O trecho abaixo roda:
MeioDePagamento[] meios = { new Pix(), new Dinheiro() };
for (MeioDePagamento meio : meios) {
IO.println(meio.nomeNoRecibo());
}
O que aparece para cada um dos dois?
Pix
Pagamento
Quem sobrescreveu responde com a própria versão; quem não sobrescreveu
responde com o corpo default, e continua compilando como antes da
mudança. Com corpo na interface, o conflito volta a ser possível: uma classe que
implemente duas interfaces com o mesmo método default é obrigada pelo
compilador a sobrescrever e decidir. O método default existe para evolução
de contrato publicado, e não para virar depósito de lógica: interface que acumula corpos está
fazendo o trabalho da construção da próxima seção.
Classe abstrata
Entre a interface, que não carrega implementação de estado nenhuma, e a classe concreta, que carrega tudo, existe o meio-termo. Os cartões do mercadinho, crédito e débito, têm taxas diferentes, mas o cálculo é o mesmo e o campo da taxa também; só a descrição de cada um varia. Uma classe abstrata guarda esse miolo comum:
abstract class CartaoBase implements MeioDePagamento {
private final BigDecimal taxa;
CartaoBase(BigDecimal taxa) {
this.taxa = taxa;
}
@Override
public final BigDecimal valorFinal(BigDecimal compra) {
return compra.multiply(BigDecimal.ONE.add(taxa));
}
public abstract String bandeira();
}
class CartaoDeCredito extends CartaoBase {
CartaoDeCredito() {
super(new BigDecimal("0.02"));
}
@Override
public String bandeira() {
return "crédito";
}
}
Classe abstrata é a classe que não pode ser instanciada: new CartaoBase()
é erro de compilação, porque ela existe para ser estendida. Ela pode ter
tudo que classe tem, campos, construtores e métodos concretos, e mais uma
coisa que classe concreta não pode: o método abstrato, declarado sem corpo,
como bandeira(), que obriga cada subclasse a fornecer a própria versão.
BigDecimal.ONE, de passagem, é a constante 1 da própria classe, irmã do
ZERO do capítulo 8, e o final em valorFinal usa a regra do capítulo 8
para congelar o cálculo que as subclasses não devem alterar.
A régua para escolher entre as três construções cabe em três linhas.
Interface quando o que existe em comum é só a promessa: é o caso de
MeioDePagamento, e é por onde se começa. Classe abstrata quando
implementações irmãs compartilham código e estado de verdade, como os
cartões. Classe concreta para o que se instancia. Na prática as três
convivem: a interface no topo, uma abstrata opcional no meio onde a
duplicação doeu, concretas embaixo.
Por que o default existe. Interfaces não aceitavam corpo nenhum nas primeiras versões de Java, e evoluir as interfaces centrais da biblioteca padrão era impossível sem quebrar o mundo inteiro. O método default entrou na versão 8 exatamente para isso, e boa parte dos métodos que os capítulos 17 a 19 usam chegou por essa porta, em interfaces que existiam desde os anos noventa.
Prática
-
Implemente
MeioDePagamentocomDinheiro,CartaoePix, oCaixaprogramado contra a interface e uma volta de impressões que feche a mesma compra pelos três meios, exibindo nome do recibo e valor final. A impressão sai com casas de sobra, porquemultiplysoma as casas dos dois lados; registre o fato e siga, que o conserto,setScale, aparece no capítulo 11. -
Reproduza a armadilha da cascata: escreva a versão com
instanceof, acrescente oPixsem tocar nela e documente o valor errado. Depois escreva em um parágrafo onde exatamente a versão com interface elimina a categoria do erro, e não só o caso. -
Acrescente
CartaoDeDebitocom taxa de 1% à hierarquia deCartaoBase, sem duplicar o cálculo. Explique por escrito por quevalorFinaléfinalna base e o que uma subclasse ganharia ou quebraria se pudesse sobrescrevê-lo. -
Declare um método novo em
MeioDePagamentosem corpodefault, anote quantos erros de compilação aparecem e onde; depois transforme-o emdefaulte observe o que muda. Registre a regra que você tirou disso. -
Modele com interface um segundo contrato do mercadinho:
Pesavel, com um método que devolve o peso em gramas, implementado porProdutoPorPesoe por uma nova classeCestaBasica. Escreva um método que receba um array dePesavele some o peso, seminstanceof.
Ficha do capítulo
| Termo | Definição |
|---|---|
| interface | conjunto de métodos que um tipo promete, sem implementação |
implements | adesão de uma classe a uma interface; corpo obrigatório para cada método |
| contrato | a promessa observável de um tipo, separada do como |
| acoplamento | grau em que um trecho depende dos detalhes de outro |
| método default | método de interface com corpo padrão; permite evoluir contrato publicado |
| classe abstrata | classe não instanciável, feita para ser estendida; aceita campos e código |
| método abstrato | método sem corpo em classe abstrata; sobrescrita obrigatória |
O contrato equals/hashCode/toString
O mercadinho recebe a segunda remessa de café da semana, e o sistema cria o objeto do produto de novo, a partir da nota do fornecedor:
void main() {
Produto primeira = new Produto("7891000100103", "Café 500g", new BigDecimal("19.90"));
Produto segunda = new Produto("7891000100103", "Café 500g", new BigDecimal("19.90"));
IO.println(primeira.equals(segunda));
}
false
Mesmo código de barras, mesmo nome, mesmo preço, e equals responde
false. Para o estoque, isso significa café duplicado no relatório: a busca
que pergunta “esse produto já existe?” com equals nunca encontra o que
procura, porque cada remessa cria um objeto novo. O capítulo 5 avisou que
equals compara conteúdo, e a frase era verdadeira para String; para as
classes nossas, ainda não, e este capítulo explica o porquê e o conserto.
O equals que rodou ali é o herdado de Object, e a versão
de Object compara identidade, como o ==: só responde true para o
mesmíssimo objeto. String responde por conteúdo porque a classe String
sobrescreve equals; Produto ainda não sobrescreveu, e igualdade por
conteúdo é uma decisão que cada classe toma por si. Tomá-la direito exige
conhecer as cláusulas.
O contrato de equals
A documentação de Object define o que toda sobrescrita de equals deve
cumprir, e o conjunto se chama contrato de equals, contrato no sentido
exato do capítulo 9: promessas que quem chama pode assumir e quem
implementa deve honrar. São cinco cláusulas, três delas com nome de
propriedade matemática.
Reflexividade: todo objeto é igual a si mesmo, x.equals(x) responde
true. Simetria: a resposta não depende da ordem, x.equals(y) e
y.equals(x) respondem o mesmo. Transitividade: se x é igual a y e y
é igual a z, então x é igual a z. Consistência: enquanto os objetos
não mudam, a resposta não muda entre chamadas. E a quinta: comparação com
null responde false, sem derrubar o programa.
Nenhuma dessas cláusulas é conferida pelo compilador, e essa é a
característica que muda o jogo: o compilador confere a assinatura e mais
nada, e todo o resto do sistema, da busca do estoque às estruturas do
capítulo 17, assume as cláusulas como verdadeiras sem perguntar. Um equals
que viola o contrato não produz erro em lugar nenhum; produz comportamento
errado nos lugares que confiaram nele, que é a definição de bug difícil.
As cláusulas parecem óbvias até a primeira implementação torta violar uma. A
simetria, por exemplo, quebra quando Produto aceita se comparar com
String de código de barras: produto.equals("7891...") diria true, e
"7891...".equals(produto) diria false, porque String não conhece
Produto. Buscas que funcionam numa direção e falham na outra são o sintoma
clássico, e a raiz é sempre uma cláusula violada.
Para o mercadinho, a decisão de domínio vem antes do código: dois produtos são o mesmo produto quando têm o mesmo código de barras. Nome e preço podem divergir entre remessas, o código não. A implementação canônica:
public class Produto {
private final String codigoDeBarras;
private final String nome;
private final BigDecimal preco;
// construtor com as validações de sempre
@Override
public boolean equals(Object outro) {
if (this == outro) {
return true;
}
if (!(outro instanceof Produto)) {
return false;
}
Produto produto = (Produto) outro;
return codigoDeBarras.equals(produto.codigoDeBarras);
}
}
A primeira conferência resolve o caso comum, mesmo objeto, sem trabalho. O
instanceof cobre o null e qualquer tipo estranho de uma vez, porque
null instanceof Produto responde false sem erro. O downcast vem protegido pela pergunta anterior, e a última linha delega a comparação
ao equals de String, que já cumpre o contrato. Cada linha dessa forma
tem função, e a assinatura tem uma exigência que a armadilha abaixo torna
inesquecível: o parâmetro é Object, não Produto.
Uma versão que parece mais limpa, com o tipo certo no parâmetro:
public boolean equals(Produto outro) {
return codigoDeBarras.equals(outro.codigoDeBarras);
}
void main() {
Produto primeira = new Produto("7891000100103", "Café 500g", new BigDecimal("19.90"));
Produto segunda = new Produto("7891000100103", "Café 500g", new BigDecimal("19.90"));
Object registro = primeira;
IO.println(primeira.equals(segunda));
IO.println(registro.equals(segunda));
}
true
false
A chamada direta respondeu true; a mesma pergunta, feita através de uma
variável Object, respondeu false.
equals(Produto) não sobrescreve
equals(Object): assinatura diferente é sobrecarga, o mesmo acidente do
capítulo 8, e as duas versões passam a conviver. A chamada através da
variável Object resolve para o equals(Object) herdado, o de identidade,
e responde false. O defeito é intermitente: nos testes diretos, com
variáveis Produto, tudo funciona; dentro das estruturas do capítulo 17,
que guardam tudo por referências genéricas, a igualdade some. @Override
pega o engano na hora, porque equals(Produto) não sobrescreve nada, e é
mais uma vez a diferença entre bug silencioso e erro de compilação.
hashCode e o código de hash
equals obriga a sobrescrever um segundo método de Object. O código de hash
de um objeto é um int que o acompanha, devolvido pelo método hashCode:
as sobrescritas corretas o derivam dos mesmos campos que o equals
compara, e o herdado de Object deriva da identidade, não do conteúdo. A
cláusula que amarra os dois métodos é curta: objetos iguais segundo
equals devem ter o mesmo código de hash. O contrário não é exigido:
objetos diferentes podem partilhar um hash, o que se chama colisão de hash,
e colisões são inevitáveis, porque há mais conteúdos possíveis do que
valores de int.
O propósito do hash é velocidade de busca: estruturas que o capítulo 17
apresenta usam o código de hash para saltar direto à vizinhança do objeto,
em vez de comparar com todos, e só usam equals para o desempate final
entre vizinhos. Quem sobrescreve equals sem sobrescrever hashCode deixa
os dois em desacordo: os cafés iguais das duas remessas ficam com hashes
herdados diferentes, a estrutura procura cada um na vizinhança do próprio
hash, e o produto guardado nunca é encontrado, sem erro nenhum. O capítulo
17 reproduz esse desaparecimento ao vivo; aqui fica a regra que o evita:
sobrescreveu um, sobrescreve o outro, derivando o hash dos mesmos campos
que o equals compara.
@Override
public int hashCode() {
return codigoDeBarras.hashCode();
}
String já sabe calcular o próprio hash, e delegar a ele cumpre a cláusula:
mesmo código de barras, mesmo hash, sempre. Quando a igualdade olha mais de
um campo, o utilitário Objects.hash(campo1, campo2), do pacote java.util, combina os hashes de
todos numa chamada, e a regra continua a mesma: os campos do hashCode são
exatamente os campos do equals. Um hash também pode ser ruim sem estar
errado: devolver sempre 42 cumpre a cláusula à risca, porque iguais têm o
mesmo hash, e destrói a velocidade, porque tudo colide com tudo e a busca
degenera em comparar com todos. Correção é obrigação; espalhamento é
qualidade, e delegar aos hashes dos próprios campos costuma entregar os
dois.
toString
O terceiro método de Object responde pela forma em texto do objeto:
IO.println(new Produto("7891000100103", "Café 500g", new BigDecimal("19.90")));
Produto@6f2b958e
Sem sobrescrita, toString devolve o nome da classe, um arroba e um número
em hexadecimal derivado da identidade; o número muda de objeto para objeto
e de execução para execução, e a saída acima é a de uma execução, não a de
todas. No arquivo-fonte compacto ainda sai o nome qualificado pela classe
implícita do arquivo, como Caixa$Produto@.... Serve para distinguir
objetos e para nada mais. Sobrescrever muda a impressão inteira,
porque IO.println chama toString de qualquer objeto que recebe, e essa é
a resposta da promessa sobre o StringBuilder impresso direto no capítulo
5:
@Override
public String toString() {
return "Produto[" + codigoDeBarras + ", " + nome + ", R$ " + preco + "]";
}
A regra de uso: toString existe para depuração e registro, para gente
lendo saída de programa, e não para dado que outro código interprete. Um
formato de verdade, recibo ou relatório, é método próprio com nome próprio;
o toString fica livre para mudar sem quebrar ninguém. O retorno do
investimento vem na primeira sessão de caça a defeito: a mensagem de uma
IllegalArgumentException que concatena o produto, ou um
IO.println(produto) jogado no meio do caixa para inspecionar, imprime
Produto[7891000100103, Café 500g, R$ 19.90] em vez de um arroba com
hexadecimal, e a diferença entre os dois é a diferença entre ler o problema
e caçá-lo. Vale sobrescrever toString em toda classe de domínio, mesmo
quando nenhum requisito pede.
O caso BigDecimal
O mesmo preço, escrito com e sem o zero final:
BigDecimal a = new BigDecimal("2.50");
BigDecimal b = new BigDecimal("2.5");
IO.println(a.equals(b));
IO.println(a.compareTo(b));
Duas linhas de saída. Quais?
false
0
Para equals, os dois são diferentes; para compareTo, o método de
comparação do capítulo 7, valem o mesmo. BigDecimal guarda o valor e a
quantidade de casas, e o equals dele compara as duas coisas: 2.50 tem duas
casas, 2.5 tem uma, e a igualdade estrita recusa. É uma decisão documentada
da classe, não um defeito, e a consequência prática cabe numa regra:
igualdade de dinheiro se pergunta com compareTo(...) == 0, nunca com
equals. Levar essa regra para dentro de um equals nosso exige cuidado
com a cláusula do hash: um equals que iguala 2.5 a 2.50 precisa de um
hashCode que também os iguale, e o caminho seguro é normalizar a escala
do campo na entrada, fixando as casas com setScale, para equals e hash
enxergarem o mesmo valor. O sintoma de esquecer é o de sempre nesta família:
valores que são o mesmo preço na etiqueta e objetos que se dizem diferentes
no código.
Hash e mutação. O código de hash é calculado a partir dos campos; se um
campo que participa do hash mudar depois que o objeto entrou numa estrutura
de busca, o objeto fica arquivado na vizinhança do hash antigo e passa a ser
procurado na do novo, sumindo sem sair do lugar. É um dos motivos de os
campos de identidade, como o código de barras, serem final, e de os tipos
feitos para chave serem imutáveis por inteiro.
Prática
-
Complete o
Produtodeste capítulo comequals,hashCodeetoStringsobrescritos, e reproduza a abertura obtendotruepara as duas remessas. -
Reproduza a armadilha da sobrecarga: mantenha
equals(Produto), mostre o par de saídas divergentes através deProdutoe deObject, e depois anote a mensagem do compilador ao pôr@Overridena versão errada. -
Quebre a simetria de propósito: faça
Produto.equalsaceitarStringde código de barras e demonstrex.equals(y)diferente dey.equals(x). Desfaça e escreva em uma frase qual cláusula estava violada. -
Sobrescreva
equalssemhashCode, imprima os hashes das duas remessas iguais e explique por escrito qual cláusula do contrato ficou violada e onde isso vai doer no capítulo 17. -
Escreva um método
mesmoPreco(Produto outro)que compare os preços comcompareTo, e demonstre um par de produtos comequalsde preçofalseemesmoPrecotrue.
Ficha do capítulo
| Termo | Definição |
|---|---|
| contrato de equals | as cláusulas que toda sobrescrita de equals deve cumprir |
| reflexividade | x.equals(x) é true |
| simetria | x.equals(y) e y.equals(x) respondem o mesmo |
| transitividade | iguais a um mesmo terceiro são iguais entre si |
hashCode | devolve o código de hash; iguais por equals têm o mesmo hash |
| código de hash | int que acompanha o objeto; sobrescrito, deriva dos campos do equals |
| colisão de hash | objetos diferentes com o mesmo hash; permitida e inevitável |
toString | a forma em texto do objeto; usada por IO.println; para depuração |
| Regra prática | |
|---|---|
parâmetro do equals | sempre Object, com @Override |
equals e hashCode | sobrescreve um, sobrescreve o outro, sobre os mesmos campos |
| dinheiro | igualdade com compareTo(...) == 0; campo BigDecimal em equals pede escala normalizada |
Records e semântica de valor
O código de barras merece deixar de ser um String solto: como texto, ele
aceita qualquer conteúdo, e a validação de treze dígitos fica espalhada por
quem lembrar dela. A ferramenta dos capítulos 7 e 10 resolve, ao preço de um
ritual completo:
public class CodigoDeBarras {
private final String digitos;
public CodigoDeBarras(String digitos) {
if (digitos == null || !digitos.matches("\\d{13}")) {
throw new IllegalArgumentException("Código de barras inválido: " + digitos);
}
this.digitos = digitos;
}
public String digitos() {
return digitos;
}
@Override
public boolean equals(Object outro) {
if (this == outro) {
return true;
}
if (!(outro instanceof CodigoDeBarras)) {
return false;
}
CodigoDeBarras codigo = (CodigoDeBarras) outro;
return digitos.equals(codigo.digitos);
}
@Override
public int hashCode() {
return digitos.hashCode();
}
@Override
public String toString() {
return "CodigoDeBarras[" + digitos + "]";
}
}
Trinta e seis linhas, todas corretas, todas já justificadas por este livro, e
todas dizendo uma única ideia: um código de barras é treze dígitos, e dois
códigos com os mesmos dígitos são o mesmo código. O mercadinho vai precisar
do mesmo ritual para item de venda, para valor em dinheiro, para endereço de
entrega, e cada repetição manual é uma chance nova de errar como a
armadilha da sobrecarga mostrou: um parâmetro Produto onde devia ser Object, um
campo esquecido no hashCode, um toString desatualizado depois que um
campo entrou. Ritual previsível que se repete e dá margem a erro é trabalho
de compilador, e a linguagem o absorveu. O
matches, novidade pontual, pergunta se o texto casa com um padrão; o
\\d{13} descreve “treze dígitos”, e a notação completa desses padrões fica
fora deste livro, com o essencial registrado na ficha.
record
public record CodigoDeBarras(String digitos) { }
Uma linha. A palavra record declara um tipo cuja definição é a lista entre
parênteses, os componentes do record, e o compilador gera o resto do ritual:
um campo private final por componente; o construtor canônico, que recebe
todos os componentes na ordem declarada; um método de acesso por componente,
com o mesmo nome dele, digitos(); e equals, hashCode e toString
sobre todos os componentes, este último no formato Nome[componente=valor],
cumprindo o contrato de equals por inteiro. Um
record é imutável por construção: não existe forma de alterar um componente
depois do new, e a imutabilidade deixa de ser disciplina
para ser garantia.
O ganho de leitura vale tanto quanto o de digitação. A declaração de um record é a forma dos dados, inteira, numa linha: quem abre o arquivo sabe que não há estado escondido, que não há mutação possível, que a igualdade é por conteúdo e cobre todos os componentes. Numa classe comum, cada uma dessas propriedades exige ler a classe inteira para confirmar; no record, elas são consequência da palavra-chave, e a leitura do tipo se resume à leitura da lista.
A abertura do capítulo 10, refeita com records:
void main() {
CodigoDeBarras primeira = new CodigoDeBarras("7891000100103");
CodigoDeBarras segunda = new CodigoDeBarras("7891000100103");
IO.println(primeira.equals(segunda));
IO.println(primeira);
}
O que imprimem as duas linhas, sem nenhum método escrito à mão?
true
CodigoDeBarras[digitos=7891000100103]
Igualdade por conteúdo e impressão legível, de fábrica, porque o compilador gerou as sobrescritas que antes se escreviam à mão. Saber escrevê-las à mão continua sendo o que separa usar o record de entendê-lo: o record não muda as regras do contrato, muda quem digita.
Construtor compacto
A linha única perdeu a validação dos treze dígitos, e recuperá-la não exige voltar ao ritual. O construtor compacto é um corpo de construtor escrito sem repetir a lista de parâmetros, que roda antes de os componentes serem atribuídos:
public record CodigoDeBarras(String digitos) {
public CodigoDeBarras {
if (digitos == null || !digitos.matches("\\d{13}")) {
throw new IllegalArgumentException("Código de barras inválido: " + digitos);
}
}
}
Sem parênteses depois do nome: essa é a grafia do compacto. Dentro dele, os
nomes dos componentes são os parâmetros recebidos, e o que o corpo pode
fazer é validar, como acima, ou normalizar, reatribuindo ao parâmetro
(digitos = digitos.strip(), com strip removendo espaços das pontas)
antes de a atribuição automática acontecer. As invariantes do capítulo 7
voltam inteiras, com uma linha de cerimônia a menos, e valem para todo
caminho de criação, porque todo caminho passa pelo canônico.
Um record também aceita construtores adicionais, com outras assinaturas,
desde que cada um comece delegando ao canônico com this(...). É a porta
para conveniências como aceitar o código de barras com espaços de nota
fiscal, ou um item de venda com quantidade implícita de um:
public ItemDeVenda(Produto produto) {
this(produto, 1);
}
A delegação obrigatória garante que a validação do compacto rode sempre, venha o objeto do caminho que vier; não existe caminho de criação que contorne a validação do canônico.
Semântica de valor
O nome do que o record materializa é semântica de valor: um tipo tem
semântica de valor quando seus exemplares valem pelo conteúdo, e dois
exemplares de mesmo conteúdo são intercambiáveis em qualquer uso. É o regime
dos primitivos, o 7 de uma conta é o 7 de outra, e o oposto do regime
de identidade, em que cada objeto é ele mesmo. O ritual da abertura constrói
semântica de valor à mão, sobrescrevendo o contrato; o record a declara de
nascença.
A régua para o mercadinho separa os tipos em duas famílias. Têm semântica de
valor os que descrevem: código de barras, um item de venda com produto e
quantidade, um valor em dinheiro. Têm identidade os que vivem: o Produto
com estoque que sobe e desce continua classe, porque duas remessas do mesmo
café são o mesmo produto com histórias diferentes, e estado mutável não cabe
em record. A regra prática: dado imutável que vale pelo conteúdo vira
record; entidade com ciclo de vida vira classe. Na dúvida, a pergunta é “faz
sentido trocar um pelo outro de mesmo conteúdo?”, e a resposta sim aponta o
record. No código profissional, os records se concentraram exatamente
nesses papéis: retratos de dados que atravessam fronteiras, a linha de um
relatório, o resultado de uma consulta, a resposta que um sistema envia a
outro, todos dados que nascem prontos, viajam e não mudam no caminho. O capítulo
12 apresenta a forma de abrir um record em padrões, componente a
componente.
Records participam do resto da linguagem sem regalias: aceitam métodos
próprios, membros static e implements de interface; o que não aceitam é
extends, nem para estender nem para serem estendidos, porque herança de
estado e valor imutável não se misturam bem, e campos de instância fora da
lista de componentes também não existem. Um item de venda mostra o formato
típico, componente mais método derivado:
public record ItemDeVenda(Produto produto, int quantidade) {
public ItemDeVenda {
if (quantidade <= 0) {
throw new IllegalArgumentException("Quantidade inválida: " + quantidade);
}
}
public BigDecimal subtotal() {
return produto.precoPara(quantidade);
}
}
Um record para as pesagens do dia da balança do queijo:
public record PesagensDoDia(int[] gramas) { }
void main() {
PesagensDoDia manha = new PesagensDoDia(new int[] { 250, 400 });
PesagensDoDia copia = new PesagensDoDia(new int[] { 250, 400 });
IO.println(manha.equals(copia));
IO.println(manha);
}
false
PesagensDoDia[gramas=[I@76ed5528]
O mesmo conteúdo deu false, e a impressão saiu ilegível.
O equals gerado compara cada componente com o equals do componente, e o
equals de array é o herdado de Object, identidade, pelo capítulo 5: dois
arrays de mesmo conteúdo são objetos diferentes. O toString do array é o
herdado também, daí o [I@ com hexadecimal. E há um furo pior: o array é
mutável, e alguém com a referência pode alterar as pesagens por fora,
minando a promessa de valor imutável. A regra que fecha as três frestas de
uma vez: componente de record deve ser imutável e ter igualdade por
conteúdo, como String, BigDecimal, primitivos e outros records. Sequência
de valores dentro de record espera o tipo certo de recipiente, que o
capítulo 17 apresenta.
Duas notas fecham a regra do componente. ItemDeVenda carrega um Produto,
entidade mutável, sem violar o que importa: a igualdade de Produto se
apoia no código de barras final, e o equals e o hash do item não se
movem quando o estoque muda. Componente pode ser entidade cuja igualdade é
estável; o que não pode é igualdade instável ou por identidade. E componente
BigDecimal herda a igualdade estrita de escala do capítulo 10: dois
records com 95.0 e 95.00 no mesmo componente não se igualam. Dinheiro que
participa da igualdade de um record pede escala fixada na entrada, no
construtor compacto, com setScale(2), o método de BigDecimal que fixa a
quantidade de casas.
Acessor sem get. O record consolidou a grafia digitos() para leitura
de componente, mas quase todo código anterior a ele segue a convenção
getDigitos(), dos tempos em que ferramentas descobriam propriedades pelo
prefixo. As duas grafias convivem no mundo real e dizem a mesma coisa; o
apêndice de legado volta ao assunto.
Prática
-
Escreva o record
CodigoDeBarrascom validação e normalização no construtor compacto, e prove com impressões: igualdade por conteúdo, recusa de código curto, espaços removidos das pontas. -
Converta
Produtopara carregar umCodigoDeBarrasem vez deString, delegandoequalsehashCodeao record, e refaça a abertura do capítulo 10 com a versão nova. -
Escreva
ItemDeVendacompleto e ummainque monte três itens, imprima cada um e some os subtotais comBigDecimal. Compare a legibilidade da impressão gerada com a que você escreveria à mão. -
Reproduza a armadilha do componente array e conserte da forma primitiva disponível: guarde as pesagens como
Stringno formato “250;400” e converta ao ler. Anote o que essa gambiarra custa e o que o capítulo 17 promete no lugar dela. -
Decida, para cada tipo do mercadinho até aqui, record ou classe:
Produto,CodigoDeBarras,ItemDeVenda,Carrinho,Caixa,MeioDePagamentoe implementações. Justifique cada decisão em uma linha, pela régua da semântica de valor.
Ficha do capítulo
| Termo | Definição |
|---|---|
| record | tipo declarado pela lista de componentes; imutável, com o ritual gerado |
| componente de record | cada item da lista; vira campo final, acessor e parte do equals |
| construtor canônico | o construtor com todos os componentes, na ordem declarada |
| construtor compacto | corpo sem lista de parâmetros, para validar e normalizar antes da atribuição |
| semântica de valor | exemplares valem pelo conteúdo; iguais são intercambiáveis |
| Regra prática | |
|---|---|
| record × classe | dado imutável que vale pelo conteúdo × entidade com ciclo de vida |
| componente | imutável e com igualdade por conteúdo; array não |
\d{13} em matches | padrão de texto “treze dígitos”; a notação completa fica fora do livro |
Sealed types, enums e pattern matching
A categoria de cada produto do mercadinho decide o corredor da prateleira e o relatório em que a venda entra, e a primeira versão a guarda como texto:
Produto cafe = new Produto("7891000100103", "Café 500g", new BigDecimal("19.90"), "mercearia");
if (cafe.categoria().equals("Mercearia")) {
IO.println("Corredor 3");
}
O if nunca acha o corredor, porque a categoria foi gravada com minúscula e
comparada com maiúscula. String aceita qualquer conteúdo: “mercearia”,
“Mercearia”, “merceária” e um erro de digitação são quatro valores
diferentes e todos válidos, e o compilador não tem como saber que o domínio
só reconhece meia dúzia de categorias. O problema é de tipo: categoria não é
texto livre, é uma escolha numa lista fechada, e a linguagem tem uma
declaração exatamente para isso.
enum: a lista fechada de valores
public enum Categoria {
MERCEARIA, HORTIFRUTI, LIMPEZA, BEBIDAS;
}
Um enum é um tipo cujos valores possíveis são declarados um a um, na própria
definição, e acabou: não existe quinto valor de Categoria, nem errado de
digitação, nem fora da lista. Cada nome declarado é uma constante de enum,
um objeto único daquele tipo, criado pela JVM; Categoria.MERCEARIA é o
mesmo objeto em qualquer ponto do programa, e por isso enum se compara com
== sem o risco do capítulo 5: identidade e conteúdo coincidem quando cada
valor existe uma vez só. O campo do produto vira
private final Categoria categoria, o construtor recebe o tipo em vez de
texto, e o erro da abertura morre na compilação: new Produto(..., "mercearia") nem compila, porque String não é Categoria.
O pagamento prometido no capítulo 4 vem no switch. Com um int na entrada,
o default era obrigatório, porque o compilador não conhecia os valores
possíveis; com um enum, ele conhece todos:
public int corredor(Categoria categoria) {
return switch (categoria) {
case MERCEARIA -> 3;
case HORTIFRUTI -> 1;
case LIMPEZA -> 4;
case BEBIDAS -> 2;
};
}
Sem default, e compila. Essa é a exaustividade: a garantia, conferida pelo
compilador, de que o switch cobre todos os casos possíveis do valor. Ela
parece detalhe até a lista crescer, e a armadilha abaixo mostra os dois
lados.
Um switch de enum escrito com default, por hábito:
public int corredor(Categoria categoria) {
return switch (categoria) {
case MERCEARIA -> 3;
case HORTIFRUTI -> 1;
default -> 4;
};
}
Meses depois, o mercadinho ganha padaria, e PADARIA entra no enum. O
programa inteiro recompila sem um aviso. Onde o pão vai parar?
No corredor 4, junto com a limpeza. O default engoliu o caso novo: para o
compilador, PADARIA está coberta, e a exaustividade que ele conferiria foi
desligada por quem escreveu default. Sem o default, a recompilação
falharia em cada switch que esqueceu a padaria, com a lista exata dos
lugares a atualizar, que é o comportamento desejável quando o domínio
cresce. A regra que sai daí: switch sobre enum não leva default; cobrir
todos os casos por extenso é o que mantém o compilador de guarda.
Constantes de enum aceitam dados e comportamento próprios, porque enum é uma classe com nascimento controlado. O corredor pertence mais à categoria do que ao método solto acima:
public enum Categoria {
MERCEARIA(3), HORTIFRUTI(1), LIMPEZA(4), BEBIDAS(2);
private final int corredor;
Categoria(int corredor) {
this.corredor = corredor;
}
public int corredor() {
return corredor;
}
}
O construtor de enum roda uma vez por constante, na carga do tipo, e não é
chamável com new: as constantes só nascem na lista do topo. Campos e métodos
seguem as regras normais dos capítulos 7 em diante.
sealed: hierarquia fechada
O enum fecha uma lista de valores; falta fechar uma lista de tipos. O caixa
do capítulo 9 devolve o resultado de um pagamento, e resultado não é um
valor único: aprovado carrega o valor cobrado, recusado carrega um motivo.
São tipos diferentes com dados diferentes, e a hierarquia aberta do capítulo
8 deixaria qualquer um criar um resultado novo que o resto do sistema não
trata. A palavra sealed fecha a hierarquia:
public sealed interface Resultado permits Aprovado, Recusado { }
public record Aprovado(BigDecimal valorCobrado) implements Resultado { }
public record Recusado(String motivo) implements Resultado { }
Uma interface ou classe sealed declara em permits a lista completa de
quem pode implementá-la ou estendê-la; qualquer tipo fora da lista é erro de
compilação. Cada tipo permitido declara o próprio destino: records e classes final
encerram o ramo; um tipo permitido pode seguir fechado, declarando-se
sealed com a própria lista; e non-sealed reabre o ramo para herança
livre, o que é raro e deliberado. O casamento com o capítulo 11 é
natural: os ramos de uma hierarquia selada costumam ser records, dados
imutáveis com o contrato pronto, e o conjunto descreve “um resultado é isto
ou aquilo, com estes dados em cada caso”.
Pattern matching
Falta consumir o resultado, e é aqui que entra o recurso que amarra o capítulo. Pattern matching (casamento de padrões) é comparar um valor contra um padrão que, ao casar, já extrai as partes. A forma mais simples é o padrão de tipo, que aposenta o par instanceof-e-cast do capítulo 8:
if (resultado instanceof Recusado recusado) {
IO.println("Recusado: " + recusado.motivo());
}
O instanceof com uma variável ao lado pergunta e converte num passo só:
casando, recusado nasce já com o tipo certo, no escopo do if, e o
downcast manual desaparece junto com o risco de ClassCastException. Sobre
uma hierarquia selada, o switch com padrões de tipo ganha a exaustividade do
enum:
public String linhaDoRecibo(Resultado resultado) {
return switch (resultado) {
case Aprovado aprovado -> "Pago: R$ " + aprovado.valorCobrado();
case Recusado recusado -> "Recusado: " + recusado.motivo();
};
}
Sem default, porque permits disse ao compilador que a lista acabou; a
mesma regra da armadilha do enum vale inteira aqui. E quando o ramo é um
record, o padrão de registro (record pattern) desestrutura os componentes
na própria cláusula:
return switch (resultado) {
case Aprovado(BigDecimal valor) -> "Pago: R$ " + valor;
case Recusado(String motivo) -> "Recusado: " + motivo;
};
Aprovado(BigDecimal valor) casa com um Aprovado e já entrega o
componente na variável valor, sem acessor e sem variável intermediária: é
a resposta à promessa deixada no capítulo 11.
O mercadinho passa a aceitar estorno, e Estornado entra na hierarquia:
public sealed interface Resultado permits Aprovado, Recusado, Estornado { }
public record Estornado(BigDecimal valorDevolvido) implements Resultado { }
Nenhum switch do sistema foi tocado. O que acontece na recompilação?
Cada switch sobre Resultado sem o caso novo vira um erro de compilação,
com a mensagem the switch expression does not cover all possible input values; quem aponta os lugares a atualizar é a lista dos próprios erros,
cada um com arquivo e linha. É a mesma mecânica
da armadilha do enum, agora a favor: o compilador entrega a lista completa
dos pontos do sistema que precisam aprender o que fazer com um estorno, e
nada compila até todos decidirem. A dupla sealed e switch exaustivo
transforma “esquecemos de tratar um caso”, o defeito silencioso clássico das
cascatas do capítulo 9, em tarefa listada pelo compilador.
Tipos de soma. A dupla “hierarquia fechada de records” mais “switch exaustivo com desestruturação” reproduz em Java o que outras linguagens chamam de tipos de soma ou uniões etiquetadas: o dado é um entre poucos formatos conhecidos, e o consumo é obrigado a tratar todos. Enum é o caso degenerado, soma de valores sem dados; sealed com records é a forma geral.
Prática
-
Converta a categoria do
Produtopara o enum com corredor e refaça a abertura do capítulo: mostre o erro de compilação ao tentar passar texto e o corredor certo saindo decategoria().corredor(). -
Reproduza a armadilha: switch de enum com
default, categoria nova, pão no corredor errado. Depois remova odefaulte anote a mensagem exata do compilador para cada switch desatualizado. -
Implemente
Resultadocom os três ramos, faça oCaixado capítulo 9 devolverResultadoem vez de valor cru (recusando pagamento acima de um limite, por exemplo) e escrevalinhaDoRecibocom padrões de registro. -
Acrescente um quarto ramo,
EmAnalise, sem dados. Decida entre record vazio e outra forma, siga os erros de compilação até o sistema inteiro tratá-lo, e conte quantos lugares o compilador listou por você. -
Volte ao exercício 3 do capítulo 8 e reescreva o trecho que usava
instanceofcom cast para a forma com padrão de tipo, comparando as duas versões por escrito.
Ficha do capítulo
| Termo | Definição |
|---|---|
| enum | tipo com a lista fechada e nomeada de valores possíveis |
| constante de enum | cada valor declarado; objeto único, comparável com == |
| exaustividade | garantia do compilador de que o switch cobre todos os casos |
sealed / permits | hierarquia fechada, com a lista completa de subtipos declarada |
non-sealed | reabre um ramo de hierarquia selada para herança livre |
| pattern matching | comparar contra um padrão que, casando, extrai as partes |
| padrão de tipo | instanceof Tipo nome: pergunta e converte num passo |
| padrão de registro | case Tipo(componentes): casa e desestrutura o record |
| Regra prática | |
|---|---|
| switch sobre enum ou sealed | sem default, para o compilador cobrar os casos novos |
| ramos de hierarquia selada | de preferência records, imutáveis e com contrato pronto |
Exceções
O caixa do mercadinho pergunta a quantidade e o operador, com pressa, digita o nome do número:
void main() {
String resposta = IO.readln("Quantidade: ");
int quantidade = Integer.parseInt(resposta);
IO.println("Registrado: " + quantidade);
}
$ java Caixa.java
Quantidade: duas
Exception in thread "main" java.lang.NumberFormatException: For input string: "duas"
at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:64)
at java.base/java.lang.Integer.parseInt(Integer.java:565)
at java.base/java.lang.Integer.parseInt(Integer.java:662)
at Caixa.main(Caixa.java:3)
O programa caiu, e um caixa que cai por erro de digitação não serve para o balcão. O capítulo 5 prometeu que sobreviver a isso era assunto para este capítulo, e a promessa vence agora. Desde o capítulo 2 o livro provoca erros de execução e lê as mensagens deles; o que faltava era o mecanismo por trás, e ele muda a pergunta: não “como evitar toda queda”, mas “quem decide o que acontece quando algo dá errado”.
A exceção é um objeto
Tudo que este livro chamou de erro de execução tem um nome próprio na
linguagem: exceção. Uma exceção é um objeto, dos comuns, criado no instante
do problema: carrega uma mensagem, o tipo que classifica o problema
(NumberFormatException, NullPointerException e as demais conhecidas) e
um retrato da pilha de chamadas do momento. O throw é quem
lança esse objeto, e a biblioteca faz o mesmo por dentro: o
parseInt da abertura executa um throw new NumberFormatException(...)
quando o texto não é número.
Lançada, a exceção interrompe o fluxo normal no ato: a linha seguinte não
roda. Ela então sobe pela pilha de chamadas, encerrando cada
método no caminho, à procura de alguém disposto a tratá-la; essa subida
chama-se propagação. Quando ninguém trata e a propagação passa do main, a
JVM encerra o programa e imprime o que a abertura mostrou. Toda queda que o
livro provocou até aqui foi exatamente isto: uma exceção que propagou até o
fim sem encontrar tratador.
A transcrição da queda tem nome, stack trace: o retrato da pilha, impresso
de cima para baixo do ponto do problema até o main. Lê-se assim: a
primeira linha dá o tipo e a mensagem; cada linha at é um método que
estava em andamento, com arquivo e linha; as de java.base são o interior
da biblioteca, com números de linha que variam de uma versão de JDK para
outra, e a primeira linha com um arquivo nosso, Caixa.java:3,
aponta onde o nosso código entrou na história. Em stack traces longos, achar
a linha do próprio programa é o primeiro gesto da leitura.
try e catch
Tratar é declarar-se disposto a receber a exceção:
void main() {
int quantidade = -1;
while (quantidade < 0) {
String resposta = IO.readln("Quantidade: ");
try {
quantidade = Integer.parseInt(resposta);
} catch (NumberFormatException erro) {
IO.println("Não entendi \"" + resposta + "\". Digite um número.");
}
}
IO.println("Registrado: " + quantidade);
}
$ java Caixa.java
Quantidade: duas
Não entendi "duas". Digite um número.
Quantidade: 2
Registrado: 2
O bloco try delimita o trecho vigiado; o catch declara o tipo de exceção
que aceita e recebe o objeto numa variável, como um parâmetro. Dando tudo
certo, o catch é ignorado; lançada uma exceção do tipo declarado dentro do
try, a execução salta o resto do bloco e entra no catch, e o programa
segue vivo depois dele. O laço em volta transforma o tratamento em política:
pergunta de novo até vir número. Um try aceita vários catch, um por
tipo, avaliados na ordem, e o polimorfismo vale aqui: catch (Exception erro)
apanha qualquer exceção do livro, porque todas descendem de Exception.
Existe uma família irmã, Error, das falhas da própria JVM, como o
StackOverflowError do capítulo 4; ela fica fora dessa rede, e é melhor
assim, porque não há tratamento sensato para a pilha estourada. A armadilha
adiante mostra por que mesmo a rede das exceções costuma ser larga demais.
A propagação atravessando dois métodos:
int converter(String texto) {
IO.println("antes do parse");
int valor = Integer.parseInt(texto);
IO.println("depois do parse");
return valor;
}
void main() {
try {
IO.println("vou converter");
int quantidade = converter("duas");
IO.println("converti: " + quantidade);
} catch (NumberFormatException erro) {
IO.println("não deu: " + erro.getMessage());
}
IO.println("caixa segue aberto");
}
Cinco IO.println no fonte. Quais rodam, e em que ordem?
vou converter
antes do parse
não deu: For input string: "duas"
caixa segue aberto
O “depois do parse” e o “converti” nunca rodam: a exceção nasceu dentro de
parseInt, encerrou converter no meio e continuou subindo até o catch
do main, pulando tudo que estava entre o ponto do lançamento e o
tratador. Depois do catch, o fluxo normal volta. A propagação atravessa
quantos métodos houver, e é isso que permite tratar o erro longe de onde ele
nasce, no nível que tem contexto para decidir; o método getMessage, usado
no tratador, devolve a mensagem que o criador da exceção escreveu.
Um tratamento escrito para “não deixar o caixa cair de jeito nenhum”:
BigDecimal total = BigDecimal.ZERO;
for (ItemDeVenda item : itens) {
try {
total = total.add(item.subtotal());
} catch (Exception erro) {
}
}
IO.println("Total do dia: " + total);
O caixa nunca cai. O fechamento do dia bate com o dinheiro na gaveta?
Não há como saber, e esse é o dano. O catch vazio engole qualquer exceção
sem registrar nada: um item com produto nulo, um subtotal que estourou uma
validação, qualquer defeito vira uma venda silenciosamente ausente do
total, e a primeira notícia é a diferença no caixa, dias depois, sem pista.
Engolir exceção é o bug silencioso em estado puro, e a rede
catch (Exception ...) agrava, porque apanha até os erros de programação
que deveriam derrubar e ser corrigidos. As duas regras que evitam o buraco:
captura-se o tipo mais específico que se sabe tratar, e todo catch faz
alguma coisa, nem que seja registrar e relançar. Cair com stack trace é
melhor do que errar em silêncio: a queda tem endereço, o silêncio não.
Checked, unchecked e throws
Nem toda exceção é igual perante o compilador, e é a divisão que decide o
que ele exige de quem chama. As exceções unchecked são RuntimeException e tudo
que descende dela, a família inteira das conhecidas deste livro:
NullPointerException, ArrayIndexOutOfBoundsException,
NumberFormatException, IllegalArgumentException, ArithmeticException,
ClassCastException. O compilador não exige nada sobre elas, porque em
geral denunciam erro de programação, e a correção é consertar o código, não
tratar. As checked são as demais descendentes de Exception: o compilador
obriga cada método em que elas podem nascer ou passar a escolher entre
tratar com catch ou declarar com throws que as deixa propagar:
public class LimiteDeFiadoException extends Exception {
public LimiteDeFiadoException(String mensagem) {
super(mensagem);
}
}
public void vender(ItemDeVenda item) throws LimiteDeFiadoException {
if (devendo.add(item.subtotal()).compareTo(limite) > 0) {
throw new LimiteDeFiadoException("Fiado estouraria o limite de R$ " + limite);
}
devendo = devendo.add(item.subtotal());
}
Criar exceção própria é estender Exception, para checked, ou
RuntimeException, para unchecked, repassando a mensagem com o super. A caderneta de fiado acima escolheu checked de propósito:
estourar o limite não é bug, é uma resposta possível do domínio, e quem
chama vender é obrigado pelo compilador a decidir na hora o que o
mercadinho faz, recusa a venda, oferece outro pagamento, chama o dono. O
throws na assinatura é o aviso público dessa obrigação, parte do contrato
do método no sentido do capítulo 9.
A régua deste livro, num assunto que divide opiniões há décadas: unchecked para violação de regra que o chamador tinha como
respeitar (argumento inválido, estado impossível), checked para condição
esperável do domínio que o chamador precisa decidir, e com parcimônia,
porque cada throws se espalha pelas assinaturas acima. Grande parte do
código moderno usa quase só unchecked; o encontro inevitável com as checked
da biblioteca acontece no capítulo 21, quando o programa tocar arquivos.
finally
O bloco finally, acoplado ao try, roda sempre: com sucesso, com exceção
tratada, com exceção propagando.
try {
IO.println(Integer.parseInt(resposta));
} catch (NumberFormatException erro) {
IO.println("entrada inválida");
} finally {
IO.println("tentativa encerrada");
}
O uso legítimo é limpeza do que precisa acontecer aconteça o que acontecer,
tipicamente devolver um recurso ao sistema. Para o caso mais comum dessa
limpeza, fechar o que foi aberto, a linguagem tem uma escrita dedicada que o
capítulo 21 apresenta junto com os arquivos, e é ela que o código atual usa;
o finally fica para as limpezas que não são fechamento e para ler o código
dos outros.
O preço de lançar. Criar uma exceção custa ordens de grandeza mais que
um if, porque o retrato da pilha é capturado no new. É um dos motivos de exceção
não servir como desvio de fluxo comum: if decide caminho esperado, exceção
sinaliza o excepcional. A regra de bolso: se o chamador vai tratar em todo
uso normal, provavelmente era um retorno, não uma exceção.
Prática
-
Blinde o caixa interativo por completo: quantidade não numérica pergunta de novo, quantidade zero ou negativa idem, com mensagens distintas, e o registro só sai com valor válido. Decida onde cada validação mora e por quê.
-
Reproduza a previsão com três métodos aninhados em vez de dois, prevendo a saída antes de rodar. Depois remova o
catche compare o stack trace impresso com a sua previsão da propagação. -
Reproduza a armadilha do
catchvazio com um item defeituoso no meio de três, mostre o total errado, e conserte duas vezes: registrando o erro e seguindo, e deixando propagar. Escreva quando cada política é a certa para um caixa de verdade. -
Implemente a caderneta de fiado com
LimiteDeFiadoExceptione ummainque venda até estourar o limite, tratando a exceção com uma mensagem ao operador. Depois converta a exceção para unchecked, observe o que o compilador deixa de exigir, e escreva qual das duas versões você defenderia na revisão de código do mercadinho. -
Escreva um método que provoque, de propósito, três exceções unchecked diferentes deste livro, conforme o argumento recebido, e um
maincom trêscatchespecíficos que identifique cada uma. Acrescente um quarto caso que nenhumcatchcubra e descreva o que acontece.
Ficha do capítulo
| Termo | Definição |
|---|---|
| exceção | objeto que descreve um problema; lançado, interrompe o fluxo |
| propagação | a subida da exceção pela pilha, encerrando métodos, até um tratador |
| stack trace | o retrato da pilha no momento do lançamento, impresso na queda |
try / catch | trecho vigiado e tratador por tipo de exceção |
finally | bloco que roda sempre, com ou sem exceção |
throws | declara que o método deixa a exceção checked propagar |
| checked | o compilador obriga a tratar ou declarar; condição esperável do domínio |
| unchecked | RuntimeException e descendentes; em geral, erro de programação |
RuntimeException | a raiz da família unchecked |
| Regra prática | |
|---|---|
| captura | o tipo mais específico que se sabe tratar |
catch vazio | nunca; registrar, reagir ou relançar |
| exceção própria | estende Exception (checked) ou RuntimeException (unchecked) |
Maven e Gradle
O mercadinho já soma uma dúzia de classes, e o ritual de colocá-lo de pé cresceu junto:
$ javac -d saida *.java
$ java -cp saida Loja
Funciona, e três incômodos moram aí. O curinga *.java alcança uma pasta
só: no momento em que os fontes se organizarem em subpastas, os pacotes do
capítulo 7, o comando deixa arquivos para trás. O classpath, digitado desde
o capítulo 2, precisa ser combinado entre todos que rodam o projeto, em
toda máquina. E o terceiro incômodo é o maior:
nenhum código de fora entra. A ferramenta de testes do capítulo 15, ou
qualquer outra peça pronta do ecossistema, chegaria como um arquivo a
baixar, guardar em algum lugar e acrescentar ao classpath, à mão, para cada
projeto e cada máquina. Sistemas reais não vivem assim; eles usam uma
ferramenta de construção, e este capítulo instala a do resto do livro.
O que uma ferramenta de construção faz
Uma ferramenta de construção é o programa que transforma o código-fonte, junto do código de fora de que ele depende, num pacote pronto para distribuir, sempre do mesmo jeito, em qualquer máquina. Ela descobre os fontes sozinha, baixa o código de fora que o projeto declarar, monta o classpath, compila na ordem certa, roda verificações e empacota o resultado. O que era uma sequência de comandos combinada entre pessoas vira um arquivo guardado junto do projeto, e “funciona na minha máquina” deixa de ser mistério: a construção é a mesma em todas.
As duas ferramentas dominantes no mundo Java são Maven e Gradle. Este livro apresenta as duas e adota o Maven do próximo capítulo em diante; os motivos fecham o capítulo, junto com o mapa para ler projetos Gradle. A instalação segue o caminho do capítulo 1:
$ sdk install maven
$ mvn -version
Apache Maven 3.9.11
...
A primeira linha da resposta confirma a instalação; as seguintes, omitidas acima, mostram qual JDK a ferramenta enxerga, que precisa ser o 25 do livro.
Maven: convenção e pom.xml
O Maven não pergunta onde estão os fontes: ele decide, e essa é a primeira ideia da ferramenta. A convenção de diretórios é o leiaute fixo de pastas que o Maven espera:
mercadinho/
├── pom.xml
└── src/
├── main/
│ └── java/ ← código do sistema
└── test/
└── java/ ← código de teste, a partir do capítulo 15
Tudo que a ferramenta produz vai para target/, uma pasta descartável que
nunca se edita nem se versiona. A convenção vale por contrato social:
qualquer pessoa que já viu um projeto Maven sabe onde está cada coisa neste,
sem ler documentação nenhuma.
O único arquivo a escrever é o pom.xml, a descrição do projeto:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>br.com.mercadinho</groupId>
<artifactId>mercadinho</artifactId>
<version>1.0</version>
<properties>
<maven.compiler.release>25</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
</project>
A escrita entre < e > é XML, um formato de texto para dados
aninhados, e o essencial dela se lê no exemplo: cada <coisa> abre e
</coisa> fecha, e o conteúdo vai no meio. As três linhas do meio
identificam o projeto: groupId é o dono, escrito como um domínio de internet de trás para a
frente, br.com.mercadinho para quem controla mercadinho.com.br,
convenção que garante nome único no mundo sem cadastro central, porque só o
dono do domínio o usaria; artifactId é o nome; version é a versão. As properties
travam a versão do Java, a 25 exigida desde o capítulo 1, e a codificação
dos fontes. Com isso e os fontes movidos para src/main/java, a construção
inteira vira:
$ mvn package
[INFO] BUILD SUCCESS
$ java -cp target/classes Loja
mvn compile compila tudo para target/classes; mvn package compila e
ainda empacota. O pacote gerado, target/mercadinho-1.0.jar, é um jar: o
formato de distribuição do Java, um arquivo único que embrulha o bytecode e
metadados, feito para entrar no classpath de outros projetos. O nome sai da
coordenada do pom.xml, e o capítulo 21 mostra a variante executável
com um único comando.
A armadilha do capítulo 2, revisitada com ferramenta. O projeto foi empacotado, o preço do café mudou no fonte, e o operador roda:
$ mvn package
[INFO] BUILD SUCCESS
$ # o preço muda em Produto.java, no editor
$ java -cp target/classes Loja
A saída mostra o preço novo ou o antigo?
O antigo. O Maven automatiza a construção, não a adivinha: target/classes
guarda o bytecode do último mvn package, e editar fonte não recompila
nada, exatamente como no capítulo 2. A diferença é que o hábito protetor
ficou barato: um único comando reconstrói o projeto inteiro, sempre, e
“editou, rodou mvn, executou” vira o ciclo padrão de trabalho.
Dependências
O ganho que justifica a cerimônia é declarar código de fora:
<dependencies>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.11.0</version>
</dependency>
</dependencies>
O trio grupo, artefato e versão é a coordenada: o endereço único de um
artefato publicado, onde artefato é qualquer pacote construído e distribuído,
tipicamente um jar. Declarada a coordenada, o mvn package baixa o artefato
do repositório, o servidor público que hospeda artefatos publicados, sendo o
Maven Central o repositório padrão do ecossistema, guarda uma cópia local e
o coloca no classpath de compilação e de execução. O exemplo acima traz uma
biblioteca de conversão de dados apenas para mostrar a forma; a primeira
dependência que o mercadinho vai realmente usar chega no capítulo 15, com a
ferramenta de testes.
Junto da dependência declarada vêm as dela: se o artefato pedido depende de
outros, o Maven os baixa também, e esses são as dependências transitivas. É
o que torna o ecossistema utilizável, ninguém declara a árvore inteira à
mão, e é também uma porta de surpresas: duas dependências que pedem versões
diferentes do mesmo artefato obrigam a ferramenta a escolher uma, e o
comando mvn dependency:tree imprime a árvore completa quando a escolha
precisar de auditoria. A regra prática: declarar o que o código importa
diretamente, deixar o transitivo para a ferramenta, e olhar a árvore quando
algo cheirar a versão trocada.
Uma classe nova, Promocao.java, criada por engano na raiz do projeto, ao
lado do pom.xml, em vez de dentro de src/main/java:
$ mvn package
[INFO] BUILD SUCCESS
A construção passa limpa. Onde está a classe?
Em lugar nenhum: fora da convenção, o arquivo é invisível para o Maven, que
compilou o resto e declarou sucesso, porque dele nada foi pedido sobre um
arquivo que ele não enxerga. O sintoma aparece depois, como
cannot find symbol em quem usar Promocao, ou como um jar sem a classe. A
convenção de diretórios não é sugestão: é o contrato de descoberta dos
fontes, e arquivo fora dela simplesmente não existe para a construção. O
mesmo vale para os testes, na pasta deles.
Gradle, e a escolha do livro
O Gradle constrói sobre os mesmos conceitos, convenção de diretórios idêntica, coordenadas idênticas, os mesmos repositórios, trocando o XML por um script de configuração:
plugins {
java
}
repositories {
mavenCentral()
}
dependencies {
implementation("com.google.code.gson:gson:2.11.0")
}
O arquivo é mais curto, o modelo de execução é mais flexível, e projetos
grandes o adotam por desempenho de construção; o preço é um script
programável onde o Maven tem um documento fixo, e mais de um jeito de fazer
cada coisa. Este livro adota o Maven daqui em diante por três motivos
práticos: é o formato que o leitor mais vai encontrar em projetos e em
respostas na internet, o pom.xml se lê sem conhecer linguagem de script
nenhuma, e tudo que o livro ensinar sobre ele se traduz para Gradle trocando
a grafia, porque os conceitos deste capítulo são os mesmos nas duas
ferramentas. Quem cair num projeto Gradle lê o build.gradle.kts com o
vocabulário daqui: coordenada, repositório, dependência, convenção.
O repositório local. Tudo que o Maven baixa fica em ~/.m2/repository,
organizado por coordenada, e cada artefato é baixado uma vez por máquina,
não por projeto. A primeira construção de um projeto novo é lenta e as
seguintes são rápidas por causa desse cache; apagar a pasta é seguro e
custa só o novo download.
Prática
-
Converta o mercadinho para a estrutura Maven: crie o
pom.xmldeste capítulo, mova os fontes parasrc/main/java, rodemvn packagee execute comjava -cp target/classes. Guarde opom.xmljunto do código, e confirme quetarget/é descartável apagando-o e reconstruindo. -
Reproduza a previsão: mude um preço, execute sem reconstruir, veja o valor antigo, reconstrua e veja o novo. Escreva a regra de trabalho em uma frase.
-
Reproduza a armadilha da classe fora da convenção e a conserte. Depois liste, com
ls target/classes, o que a construção produziu, e confira o conteúdo do jar comjar tf target/mercadinho-1.0.jar. -
Declare a dependência de exemplo do capítulo, rode
mvn packagee depoismvn dependency:tree. Identifique na árvore o que você declarou e o que veio por transitividade, e localize os arquivos correspondentes em~/.m2/repository. -
Troque o
maven.compiler.releasepara 21 e reconstrua. Anote o erro, explique-o com o capítulo 2, e desfaça.
Ficha do capítulo
| Comando | O que faz |
|---|---|
mvn compile | compila os fontes de src/main/java para target/classes |
mvn package | compila e empacota o jar em target/ |
mvn dependency:tree | imprime a árvore de dependências, transitivas incluídas |
jar tf arquivo.jar | lista o conteúdo de um jar |
| Termo | Definição |
|---|---|
| Maven | ferramenta de construção adotada pelo livro; descrita pelo pom.xml |
| Gradle | ferramenta equivalente com script de configuração; mesmos conceitos |
| convenção de diretórios | leiaute fixo (src/main/java, src/test/java, target/) que a ferramenta espera |
| artefato | pacote construído e distribuído; tipicamente um jar |
| coordenada | endereço único de um artefato: grupo, nome e versão |
| repositório | servidor que hospeda artefatos; o central é o padrão do ecossistema |
| dependência transitiva | dependência trazida por outra dependência |
| jar | arquivo único com bytecode e metadados, pronto para o classpath |
JUnit 5 e o hábito de testar
Toda correção do mercadinho, até aqui, foi conferida do mesmo jeito: rodar o
programa e olhar a saída. O método funciona para um arquivo e morre de
cansaço num sistema: a cada mudança no Caixa, alguém precisaria reexecutar
à mão a venda em dinheiro, a venda no cartão, o fiado no limite, o desconto,
o troco, e ninguém reexecuta tudo, então as quebras passam. A conferência manual
deixa de acontecer conforme o sistema cresce; a saída é escrever programas
que conferem programas.
Um teste unitário é um método que executa um pedaço pequeno do sistema, uma
unidade, e confere o resultado contra o esperado, falhando sozinho quando
eles divergem. O JUnit é a ferramenta que descobre esses métodos, roda todos
e imprime o placar, e é a primeira dependência de verdade do pom.xml,
exatamente como o capítulo 14 prometeu:
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.0</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
</plugins>
</build>
Duas novidades no XML. O <scope>test</scope> diz que a dependência existe
só para os testes: o JUnit não entra no jar do mercadinho, porque o cliente
do sistema não roda testes. E o bloco do surefire fixa a versão do
executor de testes do Maven. A trava protege contra máquinas e projetos
presos a Maven antigo, cujo executor padrão ignora testes de JUnit 5 em
silêncio, exibindo BUILD SUCCESS sem rodar teste nenhum; o Maven do
capítulo 14 já traz executor moderno, e fixar a versão mantém a construção
igual em toda máquina.
O primeiro teste
Teste é código, e mora na pasta que a convenção de diretórios reservou:
src/test/java. A classe de teste espelha a classe testada, com Test no
nome:
import java.math.BigDecimal;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class ProdutoTest {
@Test
void calculaPrecoParaTresUnidades() {
Produto cafe = new Produto("7891000100103", "Café 500g", new BigDecimal("19.90"));
BigDecimal total = cafe.precoPara(3);
assertEquals(new BigDecimal("59.70"), total);
}
}
$ mvn test
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
A anotação @Test, no espírito do @Override, marca o método
para o executor encontrar; o nome do método é a descrição do caso, e vale
uma frase inteira, porque é ele que aparece no placar quando falha. O corpo
segue um ritmo em três tempos que praticamente todo teste do mundo repete:
preparar os objetos, executar a operação, conferir o resultado.
A conferência é a asserção: uma afirmação verificável sobre o resultado, que
derruba o teste quando é falsa. assertEquals(esperado, obtido) é a
asserção central, e a ordem dos argumentos importa: o valor esperado vem
primeiro, e trocá-los não quebra nada hoje, mas inverte a mensagem de erro
de todas as falhas futuras, apontando o certo como errado. O import static
da abertura é a novidade de sintaxe: importa o método, em vez do tipo, para
assertEquals dispensar prefixo. Existem asserções irmãs para os demais
casos, assertTrue, assertFalse e assertNotNull entre elas, todas na
ficha.
Quando a asserção falha, o teste conta exatamente o quê e onde:
$ mvn test
[ERROR] CaixaTest.aplicaDescontoDeCincoPorCento:18
org.opentest4j.AssertionFailedError: expected: <57.00> but was: <57.0000>
[INFO] Tests run: 2, Failures: 1, Errors: 0, Skipped: 0
[INFO] BUILD FAILURE
A falha acima nasce de um teste sobre o método que aplica o desconto de 5% do dinheiro:
@Test
void aplicaDescontoDeCincoPorCento() {
BigDecimal resultado = caixa.comDesconto(new BigDecimal("60.00"));
assertEquals(new BigDecimal("57.00"), resultado);
}
Sessenta reais menos 5% são exatamente cinquenta e sete, e o método
multiplica por 0.95 sem errar a conta. Por que o teste falha mesmo assim?
Porque assertEquals usa o equals, e o equals de BigDecimal é o
estrito por escala: 57.00 e 57.0000 são o mesmo número, o compareTo
daria zero, e ainda assim não são iguais para o equals, porque a
multiplicação somou as casas dos dois lados. O teste está protegendo o
sistema de um jeito que a etiqueta não mostra: ou o método fixa a escala
com setScale(2), e todo valor de dinheiro do sistema circula com duas
casas, ou cada desconto acumula casas fantasmas que vão aparecer em alguma
comparação futura. Teste bom falha por motivo verdadeiro, e este é o
primeiro serviço do placar: a conversa sobre escala aconteceu no capítulo
do teste, e não numa diferença de caixa.
Fixture e o ciclo de cada teste
Testes da mesma classe repetem preparação, e a repetição tem lugar próprio.
A fixture é o conjunto de objetos que os testes de uma classe usam como
ponto de partida, e o método anotado com @BeforeEach a monta de novo antes
de cada teste:
Os imports já vistos ficam omitidos dos próximos trechos.
import org.junit.jupiter.api.BeforeEach;
class CaixaTest {
private Caixa caixa;
private Produto cafe;
@BeforeEach
void prepara() {
caixa = new Caixa();
cafe = new Produto("7891000100103", "Café 500g", new BigDecimal("19.90"));
}
@Test
void somaUmaVendaAoTotal() {
caixa.registrar(cafe, 2);
assertEquals(0, new BigDecimal("39.80").compareTo(caixa.totalDoDia()));
}
@Test
void comecaComTotalZerado() {
assertEquals(0, BigDecimal.ZERO.compareTo(caixa.totalDoDia()));
}
}
O “de novo antes de cada” é a parte que importa: cada teste recebe uma
fixture recém-criada, e a ordem em que os testes rodam deixa de ter
importância, porque nenhum herda o estado sujo do anterior. Um teste que só
passa depois de outro é um teste que mente, e a fixture por teste é o que
evita a mentira. A comparação via compareTo, nas duas asserções, é a regra de dinheiro
aplicada a testes.
Falta testar o que deve dar errado: o construtor de Produto precisa
recusar estoque negativo, e isso também é comportamento com contrato. Sem as
peças do capítulo 18, a forma disponível usa try e catch:
@Test
void recusaEstoqueNegativo() {
try {
new Produto("7891000100103", "Café 500g", new BigDecimal("19.90"), -8);
fail("Deveria ter recusado estoque negativo.");
} catch (IllegalArgumentException erro) {
assertTrue(erro.getMessage().contains("-8"));
}
}
fail derruba o teste ao ser alcançado: se o construtor aceitar o valor
inválido, a linha roda e o placar acusa. Se recusar, o catch confirma que
a mensagem carrega o valor, e o teste passa. O capítulo 18 apresenta a forma
moderna e mais curta de escrever exatamente isso.
Um teste novo entra em CaixaTest, e o placar da classe segue limpo:
void recusaQuantidadeZero() {
try {
new ItemDeVenda(cafe, 0);
fail("Deveria ter recusado quantidade zero.");
} catch (IllegalArgumentException erro) {
}
}
$ mvn test
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0 -- in CaixaTest
[INFO] BUILD SUCCESS
A validação de quantidade zero nunca foi escrita em ItemDeVenda. Por que
nada falhou?
Porque o teste nunca rodou: faltou o @Test, o método é invisível para o
executor, e o placar conta só os dois testes antigos. Nenhum erro, nenhum
aviso, e uma proteção que todo mundo acredita existir não existe. A defesa é
dupla: conferir o número do placar quando se acrescenta teste, esperando vê-lo
subir, e desconfiar de teste que nasce passando. Um teste novo deve falhar
pelo menos uma vez, na frente de quem o escreveu, antes de valer alguma
coisa; teste que nunca falhou não provou que sabe falhar.
Regressão: o bug vira teste
O uso mais valioso do JUnit num sistema vivo tem nome. Um teste de regressão é o teste escrito a partir de um defeito encontrado: antes de corrigir, escreve-se o teste que reproduz o erro e falha; corrige-se; o teste passa e fica. O defeito do dado viciado do capítulo 6 nunca mais volta sem ser notado, porque a semente fixa daquele capítulo torna o sorteio testável:
@Test
void sorteioComSementeFixaFicaNaFaixaDoDado() {
Random dado = new Random(42);
for (int volta = 0; volta < 1000; volta++) {
int face = dado.nextInt(1, 7);
assertTrue(face >= 1 && face <= 6);
}
}
É a promessa de reprodutibilidade paga: com a semente fixa, o
teste confere as mesmas mil jogadas em qualquer máquina, para sempre. E o
conjunto de testes acumulado muda a economia do sistema inteiro: refatorar o
Caixa, trocar o cálculo do desconto ou acelerar um método deixa de ser um
salto no escuro, porque o placar diz na hora o que quebrou. O contrato do
capítulo 9 dizia o que cada tipo promete; o teste é a versão executável da
promessa, conferida a cada mvn test.
De onde o JUnit conhece os testes. Nenhuma linha do mercadinho chama
ProdutoTest. O executor abre as classes de teste compiladas e pergunta a
cada uma quais métodos carregam @Test, usando um mecanismo da linguagem
para examinar tipos em execução; o capítulo 23 constrói um executor desses
do zero, em vinte linhas.
Prática
-
Adote o
pom.xmldeste capítulo no mercadinho e escrevaProdutoTestcompleto: preço para quantidade, recusa de nome vazio, recusa de estoque negativo, com o padrão try-fail-catch para as recusas. -
Escreva
CaixaTestcom fixture: total zerado no início, uma venda, três vendas somadas, e o desconto do dinheiro com a escala decidida porsetScale. Faça cada teste falhar uma vez de propósito, alterando o código testado, e desfaça. -
Reproduza a armadilha do teste sem
@Teste descreva em uma frase qual número do placar teria denunciado o problema. -
Escreva um teste de regressão para a armadilha do
equalssobrecarregado do capítulo 10: dois produtos de mesmo código comparados através deObjectdevem ser iguais. Rode-o contra a versão errada e contra a certa. -
Escreva um teste que documente o comportamento do
LimiteDeFiadoExceptiondo capítulo 13: vender dentro do limite passa, estourar o limite lança, e a mensagem carrega o limite. Decida na fixture qual limite usar.
Ficha do capítulo
| Anotação / chamada | O que faz |
|---|---|
@Test | marca o método como teste, para o executor encontrar |
@BeforeEach | roda antes de cada teste; monta a fixture |
assertEquals(esperado, obtido) | falha se diferentes; esperado vem primeiro |
assertTrue / assertFalse / assertNotNull | asserções sobre condição e presença |
fail(mensagem) | derruba o teste ao ser alcançado |
mvn test | compila e roda todos os testes de src/test/java |
| Termo | Definição |
|---|---|
| JUnit | ferramenta que descobre, executa e reporta testes |
| teste unitário | método que executa uma unidade do sistema e confere o resultado |
| asserção | afirmação verificável que derruba o teste quando falsa |
assertEquals | asserção de igualdade, via equals; dinheiro pede compareTo |
| fixture | objetos de partida dos testes, recriados antes de cada um |
| teste de regressão | teste nascido de um defeito, para ele não voltar despercebido |
Generics e type erasure
O capítulo 8 deixou anotado que uma variável Object aceita qualquer
objeto, e que a biblioteca padrão usava isso para escrever código que serve
a todos os tipos. O mercadinho tenta o mesmo truque numa prateleira de
capacidade fixa:
class Prateleira {
private final Object[] itens;
private int quantidade = 0;
Prateleira(int capacidade) {
itens = new Object[capacidade];
}
public void guardar(Object item) {
itens[quantidade] = item;
quantidade++;
}
public Object pegar(int posicao) {
return itens[posicao];
}
}
void main() {
Prateleira docas = new Prateleira(10);
docas.guardar(new Produto("7891000100103", "Café 500g", new BigDecimal("19.90")));
docas.guardar("anotação do repositor: conferir validade");
Produto produto = (Produto) docas.pegar(1);
IO.println(produto.nome());
}
$ java Recebimento.java
Exception in thread "main" java.lang.ClassCastException: class java.lang.String cannot be cast to class Produto ...
at Recebimento.main(Recebimento.java:23)
A prateleira aceita produto, aceita bilhete, aceita qualquer coisa, porque
Object aceita qualquer coisa; e tudo que sai dela sai como Object,
obrigando um downcast em cada leitura. O programa compila
inteiro e cai na leitura da posição errada, em execução, longe do guardar
que plantou o problema. O truque do Object tem exatamente esse custo:
o compilador, que passou o livro inteiro pegando engano de tipo, não
confere nada dentro dela. Generics são tipos e métodos parametrizados por
tipo, conferidos em compilação, e são o mecanismo que devolve essa
conferência ao compilador. Os experimentos deste capítulo são rascunhos de
laboratório, arquivos-fonte compactos fora do projeto Maven, para provocar
erros sem tocar o estoque real; as conclusões voltam ao projeto na
prática.
O parâmetro de tipo
class Prateleira<T> {
private final Object[] itens;
private int quantidade = 0;
Prateleira(int capacidade) {
itens = new Object[capacidade];
}
public void guardar(T item) {
itens[quantidade] = item;
quantidade++;
}
public T pegar(int posicao) {
@SuppressWarnings("unchecked")
T item = (T) itens[posicao];
return item;
}
}
O <T> depois do nome declara um parâmetro de tipo: um nome que representa
um tipo ainda não escolhido, usável dentro da classe onde qualquer tipo
seria usável. É o mesmo movimento do parâmetro de método, subido um andar: o método generaliza sobre valores, o parâmetro de tipo
generaliza sobre tipos. Quem escolhe o tipo é quem usa, entre os sinais de
menor e maior, e a escolha se chama argumento de tipo:
Prateleira<Produto> docas = new Prateleira<>(10);
docas.guardar(new Produto("7891000100103", "Café 500g", new BigDecimal("19.90")));
docas.guardar("anotação do repositor");
$ javac Recebimento.java
Recebimento.java:3: error: incompatible types: String cannot be converted to Produto
docas.guardar("anotação do repositor");
^
Com T valendo Produto, o guardar só aceita Produto, o bilhete do
repositor morreu em compilação com arquivo e linha, e o pegar devolve
Produto sem cast nenhum do lado de quem chama. O engano que era queda em
execução virou recusa do compilador, que é a direção de sempre deste livro.
O <> vazio do new pede inferência: o compilador copia o argumento de
tipo da declaração.
Dentro da classe sobrou um resto honesto: o array interno continua de
Object, e o pegar faz um cast para T que o compilador não consegue
provar, avisando com um alerta de compilação. A anotação
@SuppressWarnings("unchecked"), da família do @Override, silencia o
alerta naquele ponto, e só é decente quando a classe garante por construção
o que o compilador não vê: aqui, só T entra pelo guardar, então só T
sai. A razão de o compilador não conseguir provar é a seção do apagamento,
logo adiante.
Métodos também generalizam sozinhos, sem a classe inteira ser genérica. Um método genérico declara o próprio parâmetro de tipo antes do retorno:
static <T> T ultimoDe(T[] itens) {
return itens[itens.length - 1];
}
Chamado com um array de Produto, devolve Produto; com um array de
String, devolve String; e o compilador infere o T pelo argumento, sem
ninguém escrever o <>.
Type erasure: o tipo que a execução não vê
Duas prateleiras de tipos diferentes, e a pergunta sobre o tipo real delas.
O método getClass, herdado de Object, devolve o tipo do objeto em
execução:
Prateleira<Produto> estoque = new Prateleira<>(10);
Prateleira<String> avisos = new Prateleira<>(10);
IO.println(estoque.getClass() == avisos.getClass());
true ou false?
true
Para a execução, as duas prateleiras têm exatamente o mesmo tipo. Esse é o
type erasure, o apagamento de tipos: os parâmetros e argumentos de tipo
existem apenas para o compilador, que os usa para conferir tudo e depois os
apaga; o bytecode carrega uma Prateleira só, com Object onde havia T.
A consequência prática vem em três formas que aparecem cedo: não existe
new T(...) nem new T[...], porque na execução T não existe;
instanceof Prateleira<Produto> é recusado pelo compilador, porque a
pergunta não teria como ser respondida; e o cast para T dentro da classe
gera o alerta da seção anterior, porque vira um cast para Object que não
confere nada. A segurança dos generics é integralmente de compilação, o que
não a diminui: é onde este livro sempre quis os erros.
Por que apagar. Generics chegaram na versão 5 da linguagem, quase uma
década depois do Java 1.0, e o apagamento foi o preço da compatibilidade: o
bytecode genérico roda em bibliotecas e JVMs que nunca souberam de T, e
todo o código anterior seguiu válido. A alternativa, tipos genéricos vivos
em execução, existe em outras linguagens e cobrou delas a migração que o
Java decidiu não cobrar.
Invariância
Com tipos parametrizados vem uma regra que surpreende quem chega da
herança: Prateleira<ProdutoPorPeso> não é um subtipo de
Prateleira<Produto>, ainda que ProdutoPorPeso seja um Produto. Essa
propriedade se chama invariância: entre tipos genéricos, a relação de
subtipo dos argumentos não se propaga. O motivo é defesa, e o raciocínio
cabe em três linhas: se a atribuição fosse aceita, uma referência
Prateleira<Produto> para a prateleira dos queijos deixaria guardar um
Produto comum ali dentro, e a prateleira dos queijos passaria a conter um
não-queijo, com a falha adiada para o primeiro pegar tipado do outro
lado. O compilador recusa a atribuição para não ter que aceitar a
consequência.
Arrays fizeram a escolha oposta quase uma década antes, e ela continua na linguagem:
void main() {
ProdutoPorPeso[] queijos = new ProdutoPorPeso[3];
Produto[] produtos = queijos;
produtos[0] = new Produto("7891000100103", "Café 500g", new BigDecimal("19.90"));
IO.println("guardou");
}
O programa compila sem um aviso. O que acontece ao rodar?
$ java Deposito.java
Exception in thread "main" java.lang.ArrayStoreException: Produto
at Deposito.main(Deposito.java:4)
A atribuição de queijos para produtos compila, porque arrays propagam o
subtipo, e a JVM é obrigada a vigiar cada escrita em execução para impedir o
café na prateleira dos queijos: a vigilância falha o mais tarde possível,
com ArrayStoreException no ponto da escrita, que pode estar a um sistema
de distância da atribuição que criou o disfarce. É o mesmo problema que a
invariância dos generics mata em compilação, e a comparação das duas
escolhas é a melhor defesa da regra que surpreende.
Wildcard: aceitar a família inteira
A invariância protege e atrapalha: um método de relatório que recebe
Prateleira<Produto> recusa a prateleira dos queijos, e escrever uma versão
por subtipo seria a duplicação que o capítulo 8 condenou. O curinga resolve:
static BigDecimal valorTotal(Prateleira<? extends Produto> prateleira, int quantidade) {
BigDecimal total = BigDecimal.ZERO;
for (int i = 0; i < quantidade; i++) {
total = total.add(prateleira.pegar(i).preco());
}
return total;
}
? extends Produto lê-se “prateleira de algum tipo que é Produto”, e o
wildcard, o ?, é exatamente isso: um argumento de tipo desconhecido, com
um limite declarado. O método aceita Prateleira<Produto> e
Prateleira<ProdutoPorPeso>, e paga com uma restrição coerente: dá para ler
como Produto, e não dá para guardar coisa nenhuma, porque o tipo exato
guardável é desconhecido. O espelho para escrita, ? super, fica registrado
na ficha; no dia a dia, o ? extends de leitura responde pela maioria
esmagadora dos usos, e o capítulo 17 o traz embutido nas assinaturas que o
mercadinho vai consumir.
Prática
-
Implemente a
Prateleira<T>completa, com validação de capacidade e de posição, e reproduza as duas versões da abertura: a queda comObjecte a recusa de compilação com generics. Anote as duas mensagens. -
Escreva um método genérico
trocar(T[] itens, int i, int j)que inverta duas posições de qualquer array, e use-o com produtos e com textos. -
Reproduza a armadilha do
ArrayStoreExceptione depois tente escrever a linha equivalente comPrateleira. Anote a mensagem do compilador e explique por escrito qual dos dois momentos de erro custa mais caro num sistema. -
Prove o apagamento de dois jeitos: a previsão do
getClasse uma tentativa deinstanceof Prateleira<Produto>. Registre a mensagem do compilador da segunda. -
Escreva
contarBaratos(Prateleira<? extends Produto> p, int quantidade, BigDecimal teto)devolvendo quantos produtos custam menos que o teto, usandocompareTo. Confirme que aceita prateleiras deProdutoe deProdutoPorPeso.
Ficha do capítulo
| Termo | Definição |
|---|---|
| generics | tipos e métodos parametrizados por tipo, conferidos em compilação |
| parâmetro de tipo | o nome declarado em <T>, valendo por um tipo a escolher |
| argumento de tipo | o tipo escolhido no uso: Prateleira<Produto> |
| método genérico | método com parâmetro de tipo próprio: static <T> T ultimoDe(T[]) |
| type erasure | o apagamento: argumentos de tipo existem só para o compilador |
| invariância | Prateleira<Sub> não é Prateleira<Super>; protege a escrita |
| wildcard | ?: argumento de tipo desconhecido com limite; ? extends lê, ? super escreve |
@SuppressWarnings("unchecked") | silencia o alerta de cast que o compilador não consegue provar; só com garantia por construção |
Coleções: array vs. List, Set, Map
A Prateleira do capítulo anterior herda do array a limitação de nascença: a
capacidade é fixada no new, e um carrinho de compras não sabe de antemão
quantos itens vai ter. Crescer um array é criar outro maior e copiar tudo, e
esse serviço, junto com dezenas de outros, a biblioteca padrão já presta,
num conjunto de tipos que todo programa Java usa todos os dias:
List<ItemDeVenda> carrinho = new ArrayList<>();
carrinho.add(new ItemDeVenda(cafe, 2));
carrinho.add(new ItemDeVenda(queijo, 1));
IO.println(carrinho.size());
IO.println(carrinho.get(0).subtotal());
Este capítulo apresenta as três estruturas que respondem pela quase
totalidade do uso real, List, Set e Map, com o critério de escolha
entre elas, e a árvore de interfaces que as organiza, completada pela fila,
Queue. Como cada estrutura funciona por dentro é assunto de estrutura de
dados, uma disciplina inteira que fica fora deste livro; o que entra aqui é
o que se usa e o que se pergunta em entrevista.
A árvore: de Iterable a Queue
Os tipos deste capítulo não são avulsos: formam uma árvore de interfaces
ligadas por herança, e quem conhece o desenho dela lê dezenas de
assinaturas da biblioteca sem decorar nenhuma. A definição que ancora tudo:
uma coleção é um objeto que reúne múltiplos elementos numa única unidade.
No topo da árvore está Iterable, o contrato de quem pode ser percorrido,
com um único método essencial, iterator(), que entrega o objeto de
percurso; é exatamente o que o for-each exige, e qualquer Iterable serve
nele (o array, que fica fora da árvore, recebe do compilador um
tratamento à parte). De Iterable herda Collection, o contrato geral
das coleções de fato: o que vale para qualquer grupo de elementos mora nela,
add, remove, contains, size, isEmpty. E de Collection descem as
três especializações, cada uma acrescentando uma promessa ao contrato de
Collection: List promete posição, Set promete ausência de duplicata, e Queue
promete uma disciplina de saída, tipicamente a ordem de chegada.
Map fica fora da árvore de propósito: um dicionário de chave para valor
não é um grupo de elementos soltos, e forçá-lo no contrato de Collection
estragaria os dois. Ele encabeça uma hierarquia própria, paralela e menor.
flowchart TD
I["Iterable"] --> C["Collection"]
C --> L["List"]
C --> S["Set"]
C --> Q["Queue"]
Q --> D["Deque"]
M["Map"]
O padrão de leitura da biblioteca sai do diagrama: a interface diz o
contrato, e a implementação diz a mecânica no prefixo do nome, ArrayList
para List, HashSet para Set, HashMap para Map, ArrayDeque para
a fila. O resto do capítulo desce a árvore ramo por ramo.
List e ArrayList
List é uma interface, no sentido pleno da palavra: o contrato de uma
sequência de tamanho variável, com posição, cujos métodos aposentam o
improviso de crescer array à mão: add acrescenta no fim, get(i) lê pela
posição, size() conta, remove tira, contains procura usando equals.
ArrayList é a implementação padrão do contrato: guarda os elementos num
array interno e o troca por um maior quando enche, sozinha. A declaração
segue a lição de programar contra o contrato:
List<ItemDeVenda> carrinho = new ArrayList<>();
A variável é do tipo da interface e o new escolhe a implementação, no
mesmo desenho do MeioDePagamento: o resto do código depende só de List,
e trocar a implementação um dia não toca linha nenhuma. O parâmetro de
tipo é quem faz get devolver ItemDeVenda sem cast e o compilador
recusar qualquer intruso no add; a assinatura do construtor de cópia,
new ArrayList<>(colecao), traz o wildcard ? extends trabalhando,
aceitando qualquer Collection.
O repertório de List vai além do essencial da abertura, e o cartaz de
ofertas da feira serve de bancada:
List<String> cartaz = new ArrayList<>();
cartaz.add("banana");
cartaz.add("alface");
cartaz.add("tomate");
cartaz.add(1, "couve");
cartaz.set(0, "banana prata");
IO.println(cartaz);
IO.println(cartaz.indexOf("tomate"));
[banana prata, couve, alface, tomate]
3
O add de dois argumentos insere na posição dada e empurra os seguintes
uma casa adiante, por isso a couve entrou na posição 1 e a alface passou
para a 2. O set troca o elemento da posição pelo novo, sem empurrar
ninguém, e devolve o que saiu. O indexOf procura com equals e devolve
a posição da primeira ocorrência, ou -1 quando não encontra, a convenção
de “não achei” das buscas por posição. O repertório do dia a dia, na mesma
bancada:
| Chamada | Efeito |
|---|---|
cartaz.add("tomate") | acrescenta no fim |
cartaz.add(1, "couve") | insere na posição 1 e empurra os seguintes |
cartaz.get(0) | lê pela posição |
cartaz.set(0, "banana prata") | troca na posição, sem empurrar, e devolve o que saiu |
cartaz.remove(2) | tira pela posição |
cartaz.remove("alface") | tira a primeira ocorrência igual, pelo equals |
cartaz.contains("tomate") | pergunta pela presença, com equals |
cartaz.indexOf("tomate") | posição da primeira ocorrência; -1 se não achar |
cartaz.size() / cartaz.isEmpty() | quantos elementos / se está vazia |
cartaz.addAll(outroCartaz) | despeja outra coleção inteira no fim |
cartaz.clear() | esvazia |
As duas formas de remove, pela posição e pelo elemento, carregam uma
armadilha guardada para a seção dos wrappers. Sobre o custo, o suficiente
para decidir: get e set são saltos diretos no array interno, e inserir
ou remover no meio empurra os elementos seguintes, um preço que só aparece
em listas grandes.
A pergunta de entrevista mora na segunda implementação da ficha:
LinkedList guarda os elementos em nós encadeados, cada um apontando para o
seguinte, em vez de num array contíguo. Na teoria, inserir no meio dela é
mais barato; na prática, percorrer nós espalhados pela memória perde de
longe para varrer um array contíguo, e o get(i) dela precisa caminhar até
a posição. A resposta honesta, que serve para a entrevista e para o código:
ArrayList é a escolha padrão, praticamente sempre, e LinkedList é a
resposta de uma pergunta clássica, não uma ferramenta do dia a dia.
Wrappers e autoboxing
List<int> não compila. Parâmetro de tipo aceita só tipo de objeto, e para
cada primitivo a biblioteca tem uma classe wrapper que o
embrulha como objeto: Integer para int, Long, Double, Boolean e
Character para os demais. O Integer.parseInt do capítulo 5 morava
exatamente nessa classe, e a promessa de apresentar a família vence aqui. A
conversão é automática nas duas direções: autoboxing na ida do primitivo
para o wrapper, unboxing na volta.
List<Integer> quantidades = new ArrayList<>();
quantidades.add(3);
int primeira = quantidades.get(0);
O 3 entra como Integer e sai como int sem cerimônia visível, e é fácil
esquecer que wrapper é objeto, com tudo que vale para objetos:
Quatro caixas de leite e um limite de estoque:
void main() {
Integer contagem = 127;
Integer limite = 127;
IO.println(contagem == limite);
Integer contagemMaior = 128;
Integer limiteMaior = 128;
IO.println(contagemMaior == limiteMaior);
}
true
false
O mesmo código, com 127 responde true e com 128 responde false.
== entre wrappers compara referências, como entre quaisquer objetos, e o
resultado depende de os dois lados serem o mesmo objeto. Para valores
pequenos eles costumam ser, pelo mecanismo do aprofundamento abaixo; de 128
em diante deixam de ser, e a comparação que passou em todos os testes
pequenos erra em produção com os números grandes. A regra de sempre não
ganha exceção para números: wrapper se compara com equals, e
contagemMaior.equals(limiteMaior) responde true sempre. Uma variante dessa
armadilha: um Integer nulo atribuído a um int explode com
NullPointerException na conversão automática, e a linha nem parece tocar
em referência.
O cache dos wrappers. A conversão automática de int para Integer
reaproveita objetos prontos para os valores de −128 a 127, e por isso dois
127 são o mesmo objeto e dois 128 não. O intervalo pequeno é justamente
o mais usado em teste, o que faz o == de wrapper parecer correto até o
primeiro valor grande de verdade.
A armadilha prometida na seção de List também nasce dos wrappers.
O painel de senhas do açougue guarda as senhas em espera, e o cliente da senha 2 foi embora:
void main() {
List<Integer> senhasEmEspera = new ArrayList<>();
senhasEmEspera.add(5);
senhasEmEspera.add(2);
senhasEmEspera.add(8);
senhasEmEspera.remove(2);
IO.println(senhasEmEspera);
}
[5, 2]
A senha 2 continua na lista, e a senha 8 sumiu.
List tem dois remove: um recebe int e tira pela posição, outro recebe
objeto e tira o elemento igual. O literal 2 casa exato com a versão de
posição, e o compilador nem considera o autoboxing, porque entre uma
conversão exata e uma que embrulha, a exata vence sempre. A linha removeu a
posição 2, que era a senha 8, sem erro e sem aviso, e o painel seguiu
chamando um cliente que já foi atendido. A forma de dizer “o valor 2” é
embrulhar de propósito: senhasEmEspera.remove(Integer.valueOf(2)), e o
teste da fila de senhas pega a diferença.
Set e HashSet: sem duplicatas
Set é o contrato do conjunto: coleção que recusa duplicata, onde duplicata
é definida pelo equals. HashSet é a implementação padrão, e o nome
entrega a mecânica: é a estrutura que usa o código de hash
para achar a vizinhança de um elemento num salto, em vez de comparar com
todos. O estoque do mercadinho não deve ter o mesmo produto duas vezes, e o
Set transforma essa regra de disciplina em propriedade da estrutura: o
add de um produto igual a um presente devolve false e não insere.
| Chamada | Efeito |
|---|---|
estoque.add(produto) | insere; devolve false e não insere se um igual já está lá |
estoque.contains(produto) | pergunta pela presença, pelo hash e depois pelo equals |
estoque.remove(produto) | tira o elemento igual, se houver |
estoque.size() / estoque.isEmpty() | herdados de Collection, como em List |
O capítulo 10 prometeu mostrar ao vivo o preço de sobrescrever equals sem
hashCode, e o palco é este:
Um Produto com equals correto pelo código de barras e hashCode
esquecido, herdado de Object:
void main() {
Set<Produto> estoque = new HashSet<>();
estoque.add(new Produto("7891000100103", "Café 500g", new BigDecimal("19.90")));
IO.println(estoque.contains(new Produto("7891000100103", "Café 500g", new BigDecimal("19.90"))));
}
false
Os dois objetos são iguais pelo equals, o produto está no conjunto, e o
contains diz que não.
O HashSet procura pelo hash primeiro: calcula o do produto perguntado,
salta para a vizinhança correspondente e só ali usa equals. Com o
hashCode herdado, os dois cafés iguais têm hashes de identidade
diferentes, a busca olha a vizinhança errada e o produto some sem sair do
lugar. Pior: por coincidência de vizinhança, uma execução aqui e ali pode
encontrar, e defeito intermitente escapa do teste e volta em produção. É a cláusula
do contrato de equals cobrada pela estrutura que confia nela, e a correção
é a de sempre: sobrescreveu equals, sobrescreve hashCode, sobre os mesmos campos.
Queue e Deque: a disciplina da fila
O mercadinho anota encomendas por telefone e as entrega na ordem em que
chegaram. Queue é o contrato dessa disciplina: a fila, em que os
elementos saem na ordem em que entraram, o regime FIFO (first in, first
out, primeiro a entrar, primeiro a sair):
Queue<String> encomendas = new ArrayDeque<>();
encomendas.offer("Dona Marta: 2 café, 1 açúcar");
encomendas.offer("Seu Jorge: 1 queijo minas");
IO.println(encomendas.peek());
IO.println(encomendas.poll());
IO.println(encomendas.poll());
IO.println(encomendas.poll());
Dona Marta: 2 café, 1 açúcar
Dona Marta: 2 café, 1 açúcar
Seu Jorge: 1 queijo minas
null
offer entra no fim, poll sai pela frente, e peek espia a frente sem
tirar; com a fila vazia, poll e peek devolvem null em vez de lançar,
e o percurso de entregas termina quando o poll devolve o primeiro null.
A implementação, ArrayDeque, cumpre na verdade um contrato mais rico:
Deque (double-ended queue, fila de duas pontas) estende Queue com
operações nas duas extremidades. Com as duas pontas, o mesmo objeto serve
de pilha, a estrutura em que o último a entrar é o primeiro a sair, o
regime LIFO (last in, first out), o mesmo desenho da pilha de chamadas:
push empilha e pop desempilha, ambos na mesma ponta. A classe Stack,
dos primeiros anos da linguagem, fazia esse papel e sobrevive em código
antigo; código novo empilha com ArrayDeque.
| Chamada | Efeito |
|---|---|
encomendas.offer(pedido) | entra no fim da fila |
encomendas.poll() | sai pela frente; null se a fila está vazia |
encomendas.peek() | espia a frente sem tirar; null se vazia |
pilha.push(pedido) | empilha, com o Deque servindo de pilha |
pilha.pop() | desempilha da mesma ponta |
No uso real a fila aparece
menos que List e Map; na entrevista, FIFO contra LIFO é pergunta de
aquecimento, e a resposta agora tem código.
Map e HashMap: de chave para valor
Map é o contrato do dicionário: pares de chave e valor, com busca pela
chave. É a estrutura do estoque de verdade, do código de barras para o
produto, e HashMap é a implementação padrão, com a mesma mecânica de hash
do HashSet aplicada às chaves:
Map<String, Produto> estoque = new HashMap<>();
estoque.put("7891000100103", cafe);
estoque.put("7891000200100", queijo);
Produto achado = estoque.get("7891000100103");
Produto ausente = estoque.get("0000000000000");
IO.println(achado.nome());
IO.println(ausente);
Café 500g
null
put grava o par, sobrescrevendo o valor se a chave já existia, e get
devolve o valor ou null quando a chave não está lá: é o reencontro
marcado no capítulo 5, a busca que pode legitimamente não ter resposta.
Consultar antes com containsKey, ou usar getOrDefault(chave, valorPadrao),
evita o NullPointerException de quem esquece o caso ausente. O idioma de
contagem, presente em todo sistema e em toda entrevista, junta as duas
pontas:
var vendasPorCategoria = new HashMap<Categoria, Integer>();
for (ItemDeVenda item : itensDoDia) {
Categoria categoria = item.produto().categoria();
int atual = vendasPorCategoria.getOrDefault(categoria, 0);
vendasPorCategoria.put(categoria, atual + item.quantidade());
}
O var do capítulo 3, prometido para quando os nomes de tipo crescessem,
entra em cena aqui, com uma ressalva: ele deduz o tipo concreto, HashMap
e não Map, então serve às variáveis locais curtas e cede a vez quando a
disciplina da interface importa. E o percurso idiomático dos pares usa
entrySet():
for (Map.Entry<Categoria, Integer> entrada : vendasPorCategoria.entrySet()) {
IO.println(entrada.getKey() + ": " + entrada.getValue());
}
Cada entrada carrega chave e valor juntos, sem a segunda busca que o par
keySet e get faria; Entry é um tipo declarado dentro de Map, e daí
vem o nome composto Map.Entry. O repertório de Map, sobre o estoque:
| Chamada | Efeito |
|---|---|
estoque.put(codigo, produto) | grava o par; sobrescreve o valor se a chave já existia |
estoque.get(codigo) | o valor, ou null quando a chave não está lá |
estoque.getOrDefault(codigo, padrao) | o valor, ou o padrão, sem null no caminho |
estoque.containsKey(codigo) | pergunta se a chave existe |
estoque.remove(codigo) | tira o par da chave |
estoque.keySet() / estoque.entrySet() | as chaves / os pares, para percorrer |
estoque.size() / estoque.isEmpty() | quantos pares / se está vazio |
Ordem de iteração
As vendas do dia entram no mapa na ordem em que aconteceram:
Map<String, Integer> vendas = new HashMap<>();
vendas.put("Café 500g", 3);
vendas.put("Arroz 5kg", 1);
vendas.put("Sabão em pó", 2);
vendas.put("Queijo minas", 4);
for (String nome : vendas.keySet()) {
IO.println(nome + ": " + vendas.get(nome));
}
Em que ordem os quatro produtos saem?
Arroz 5kg: 1
Queijo minas: 4
Café 500g: 3
Sabão em pó: 2
Em ordem nenhuma que se reconheça: nem a de inserção, nem a alfabética. A
ordem de iteração de um HashMap é consequência dos hashes das chaves, um
detalhe interno sem promessa nenhuma, que muda entre versões e
implementações. Relatório que depende da ordem de um HashMap é bug agendado, e a
regra é declarar a ordem quando ela for requisito. TreeMap é a
implementação que mantém as chaves ordenadas, e a ordem vem de Comparable:
a interface de quem sabe se comparar com os da própria espécie, com um único
método, compareTo, devolvendo negativo, zero ou positivo, exatamente o
protocolo do BigDecimal. String e os wrappers
já a implementam, e trocar new HashMap<>() por new TreeMap<>() na
previsão faria os produtos saírem em ordem alfabética. Existe ainda o meio-termo, e ele cai em entrevista: LinkedHashMap guarda
os pares com a mecânica do HashMap e lembra a ordem de inserção; na
previsão acima, devolveria os quatro produtos na ordem em que entraram. A
resposta de entrevista vira um trio, nos mesmos moldes da de List:
HashMap por padrão, pela busca em um salto e sem promessa de ordem;
LinkedHashMap quando a ordem de chegada importa; TreeMap quando a
ordem das chaves é requisito do problema, pagando a busca mais lenta.
Um tipo nosso entra no TreeMap implementando a interface:
public class Produto implements Comparable<Produto> {
@Override
public int compareTo(Produto outro) {
return nome.compareTo(outro.nome);
}
}
E quando a ordem desejada não é a natural do tipo, ou o tipo não tem nenhuma, o capítulo 18 traz a peça que falta.
Coleções imutáveis
As três interfaces têm fábricas de exemplares que nascem prontos e não mudam:
List<String> diasDeFeira = List.of("terça", "sexta");
Set<String> meiosAceitos = Set.of("dinheiro", "pix", "cartão");
Map<Categoria, BigDecimal> descontosDaFeira = Map.of(Categoria.HORTIFRUTI, new BigDecimal("0.10"));
Uma coleção imutável recusa add, remove e put com um erro de execução
imediato e barulhento, UnsupportedOperationException, e o erro imediato é
o serviço prestado: ele transforma “ninguém deveria mexer nisto” em
“ninguém consegue”. Os usos
que pagam a passagem: constantes de domínio, como os dias de feira, e
retorno defensivo, devolvendo List.copyOf(itens) para quem pede a lista
interna, em vez da referência viva que outros alteram por fora. E a pendência do capítulo 11 fecha aqui: sequência dentro
de record é List imutável, garantida no construtor compacto:
public record PesagensDoDia(List<Integer> gramas) {
public PesagensDoDia {
gramas = List.copyOf(gramas);
}
}
O copyOf blinda contra a lista viva de quem chamou, o equals gerado
passa a comparar conteúdo, porque List o faz, e a armadilha do componente
array morre de vez.
Resta pagar o topo da árvore: o iterador, que o iterator() de Iterable
entrega, é o objeto que percorre uma coleção, um elemento por vez, e é ele
que o for-each usa por baixo em tudo que este capítulo mostrou. O encontro direto com ele costuma acontecer
de um jeito específico: alterar a coleção no meio de um for-each costuma derrubar o
programa com ConcurrentModificationException, um erro barulhento de
propósito. A detecção, porém, é melhor esforço, não garantia: há casos, como
remover o penúltimo elemento de um ArrayList, em que o percurso termina em
silêncio, corrompido. A regra vale sem exceção de sorte: não alterar dentro
do for-each, nunca; a remoção por critério tem forma própria e curta no
capítulo 18.
Prática
-
Refaça o
Carrinhodo capítulo 8 sobreList<ItemDeVenda>, comtotal()emBigDecimal, e aposente os arrays paralelos de vez. -
Reproduza a armadilha dos wrappers com os valores 127 e 128, conserte com
equalse escreva a regra em uma frase. Depois provoque oNullPointerExceptiondo unboxing com umIntegernulo, e refaça o painel de senhas removendo a senha certa comInteger.valueOf. -
Modele a fila de encomendas da entrega em domicílio com
Queue<String>: chegada comoffer, atendimento compoll, espiada compeek, e o encerramento correto quando a fila esvazia. Depois use umArrayDequecomo pilha e escreva em uma frase a diferença entre os dois regimes. -
Reproduza o desaparecimento no
HashSetcom umProdutosemhashCode, conserte sobrescrevendo, e rode dez vezes cada versão anotando os resultados. Explique por escrito por que a versão errada poderia passar num teste. -
Monte o estoque como
Map<String, Produto>com cinco produtos, escreva a consulta que responde “existe? qual o preço?” sem risco deNullPointerException, e o relatório de contagem por categoria comgetOrDefault. -
Monte um
TreeMap<Produto, Integer>de vendas por produto e observe a ordem seguir ocompareTopor nome. Depois troque a ordem natural deProdutopara preço, decidindo comcompareTodeBigDecimal, e anote o que muda na saída. -
Escreva um método que receba
List<ItemDeVenda>e devolva uma versão imutável dela, e prove com uma tentativa deaddque a devolução é segura.
Ficha do capítulo
| Chamada | O que faz |
|---|---|
add / get(i) / size() / remove / contains | o essencial de List |
add(i, e) / set(i, e) / indexOf / isEmpty / clear / addAll | o resto do dia a dia de List |
offer / poll / peek | fila: entra no fim, sai da frente, espia; vazia devolve null |
push / pop | pilha sobre Deque: empilha e desempilha na mesma ponta |
put / get / getOrDefault / containsKey | o essencial de Map; get ausente devolve null |
keySet() / entrySet() | as chaves / os pares, para percorrer |
List.of, Set.of, Map.of, List.copyOf | coleções imutáveis prontas |
| Termo | Definição |
|---|---|
| coleção | objeto que reúne múltiplos elementos numa única unidade |
Iterable | o contrato de quem pode ser percorrido; iterator(); exigência do for-each |
Collection | o contrato de que List, Set e Queue herdam; Map fica à parte |
List / ArrayList | sequência com posição / implementação padrão, sobre array que cresce |
LinkedList | nós encadeados; resposta de entrevista, raramente a escolha certa |
Set / HashSet | conjunto sem duplicatas (por equals) / implementação por hash |
Map / HashMap | pares chave-valor / implementação por hash, sem ordem prometida |
LinkedHashMap | mecânica de HashMap lembrando a ordem de inserção |
TreeMap | chaves mantidas em ordem, via Comparable |
Queue / ArrayDeque | a fila FIFO: sai na ordem de chegada / implementação padrão |
Deque | fila de duas pontas; serve de fila e de pilha (LIFO) |
Comparable / compareTo | a ordem natural de um tipo: negativo, zero, positivo |
| classe wrapper | o primitivo como objeto: Integer, Double e os demais |
| autoboxing / unboxing | conversão automática primitivo→wrapper e a volta; == continua proibido |
| iterador | o objeto que percorre a coleção; motor do for-each |
| ordem de iteração | HashMap/HashSet não prometem nenhuma; TreeMap promete a das chaves |
| coleção imutável | nasce pronta e recusa alteração com erro imediato |
Lambdas e interfaces funcionais
O Produto saiu do capítulo 17 sabendo se comparar por nome, e o relatório
de reposição quer outra coisa: os produtos do estoque em ordem de preço. A
ordem natural do Comparable é uma só por tipo, e ordem de relatório muda a
cada relatório. O método sort de List aceita a ordem como argumento, e a
linha que a entrega apresenta a sintaxe deste capítulo:
List<Produto> estoque = new ArrayList<>(estoqueCompleto);
estoque.sort((a, b) -> a.preco().compareTo(b.preco()));
O new ArrayList<>(colecao) copia a coleção recebida, deixando a original
em paz para o relatório ordenar à vontade. O trecho (a, b) -> a.preco().compareTo(b.preco()) é uma lambda: uma função
sem nome, escrita como expressão, com os parâmetros antes da seta e o
resultado depois. Até aqui, todo comportamento do livro morava em métodos
com nome, dentro de tipos; a lambda deixa escrever um comportamento pequeno
no exato lugar onde ele é usado, e entregá-lo como argumento, como se
entrega um valor. É a peça que faltava para as coleções renderem o que
prometem, e o capítulo 19 é inteiro construído sobre ela.
A sintaxe e o alvo
A lambda tem três formas, da mais curta para a mais completa:
p -> p.estoque() == 0
(a, b) -> a.preco().compareTo(b.preco())
(Produto p) -> {
IO.println(p.nome());
return p.preco();
}
Um parâmetro dispensa parênteses; o corpo de uma expressão só dispensa
chaves e return; o corpo em bloco escreve os dois. Os tipos dos parâmetros
quase nunca aparecem, porque o compilador os infere, e a pergunta certa é de
onde: toda lambda nasce num contexto que espera um tipo específico, e esse
tipo é sempre uma interface funcional, a interface com um único método
abstrato. A lambda é um atalho para implementar exatamente esse método: os
parâmetros dela são os do método, o corpo dela é o corpo dele, e o
compilador confere tudo contra a assinatura. O sort espera um
Comparator<Produto>, cujo único método recebe dois produtos e devolve
int; a lambda da abertura é uma implementação dele, sem classe, sem nome e
sem cerimônia.
O MeioDePagamento tem um único método abstrato, portanto é uma interface
funcional sem saber, e um teste pode fabricar um meio de pagamento de
mentira numa linha:
MeioDePagamento semTaxa = compra -> compra;
A anotação @FunctionalInterface, opcional e recomendada, marca a intenção
na interface e faz o compilador recusar um segundo método abstrato, no
espírito do @Override.
As quatro famílias prontas
A biblioteca padrão traz interfaces funcionais de prateleira para os quatro
papéis que cobrem quase todo uso, todas no pacote java.util.function:
Predicate<T> testa e responde boolean. É o tipo do critério, e o
removeIf que o capítulo 17 deixou prometido o recebe:
estoque.removeIf(p -> p.estoque() == 0);
Function<T, R> transforma um valor em outro: recebe T, devolve R.
Function<Produto, String> etiqueta = p -> p.nome() + ", R$ " + p.preco();
Consumer<T> recebe um valor e não devolve nada, só age; o forEach das
coleções o aceita:
estoque.forEach(p -> IO.println(p.nome()));
Supplier<T> é o espelho: não recebe nada e fornece um valor a cada
chamada, pelo método get.
Um fornecedor de produto padrão, declarado e depois usado:
void main() {
Supplier<Produto> padrao = () -> {
IO.println("criando o produto padrão");
return new Produto("0000000000000", "Sem cadastro", BigDecimal.ZERO);
};
IO.println("fornecedor pronto");
Produto p = padrao.get();
IO.println(p.nome());
}
Em que ordem saem as três impressões?
fornecedor pronto
criando o produto padrão
Sem cadastro
Declarar a lambda não executa o corpo: a linha do Supplier só guarda o
comportamento, e “criando” aparece apenas quando alguém chama get. Lambda
é um valor que carrega comportamento ainda não executado, e quem decide o
momento da execução é quem a recebe; essa inversão, modesta aqui, é o motor
central do capítulo 19.
Comparator e a referência de método
Comparator merece seção própria porque é a interface funcional mais usada
fora das quatro famílias, e porque ela apresenta o segundo atalho da
sintaxe. A fábrica Comparator.comparing monta o comparador a partir da
função que extrai a chave de ordenação:
estoque.sort(Comparator.comparing(Produto::preco));
estoque.sort(Comparator.comparing(Produto::preco).reversed());
estoque.sort(Comparator.comparing(Produto::categoria)
.thenComparing(Produto::nome));
Produto::preco é uma referência de método: quando a lambda inteira seria
só “chame este método”, os dois-pontos duplos a escrevem sem inventar
parâmetros. p -> p.preco() e Produto::preco são o mesmo comportamento; a
referência existe para a leitura, e vale usá-la sempre que a lambda não faz
nada além da chamada. Há três formas: a referência a método de instância
pelo tipo, como Produto::preco; a referência a método estático, família
de Integer::parseInt e também de IO::println, e é por isso que
estoque.forEach(IO::println) compila; e a referência a método de um
objeto já existente, como relatorio::append com um StringBuilder em
mãos. E quando a ordem desejada é a natural do Comparable,
Comparator.naturalOrder() a entrega como comparador.
Parente visual dessa sintaxe, e coisa completamente diferente, é o literal
de classe: Produto.class, o objeto que representa o próprio tipo em
execução. Ele estreia de verdade na seção seguinte e reina no capítulo 23.
O clube de fidelidade do mercadinho guarda o saldo de pontos como int, e
estorno deixa saldo negativo. O extrato ordena os saldos subtraindo:
List<Integer> saldos = new ArrayList<>(List.of(10, 2_000_000_000, -300_000_000));
saldos.sort((a, b) -> a - b);
IO.println(saldos);
[10, 2000000000, -300000000]
A lista saiu em ordem nenhuma, sem erro nenhum.
A subtração devolve o sinal certo enquanto não estoura, e o estouro exige
exatamente o que o exemplo tem: sinais opostos com magnitudes grandes.
2_000_000_000 - (-300_000_000) passa do teto do int, sofre o overflow
do capítulo 3, dá a volta e sai negativo, dizendo ao sort que dois
bilhões vêm antes de trezentos milhões negativos. As magnitudes aqui são de
laboratório; o mecanismo não é. Com estoques, não negativos por invariante
desde o capítulo 7, a subtração nunca estoura; com qualquer chave que possa
ser negativa, saldo, variação, diferença, ela é defeito latente, e como
ninguém audita faixas a cada uso, a regra é uma só: comparador não subtrai.
Integer.compare(a, b) compara sem estourar, e
Comparator.comparingInt faz o mesmo por dentro.
Captura, e o efetivamente final
Uma lambda enxerga as variáveis do escopo onde nasceu, e usá-las tem nome: captura de variável.
BigDecimal teto = new BigDecimal("10.00");
estoque.removeIf(p -> p.preco().compareTo(teto) > 0);
O teto não é parâmetro da lambda: veio capturado do método em volta. A
regra que governa a captura: uma variável local só pode ser capturada se
for efetivamente final, isto é, se nunca for reatribuída depois de receber
valor, tenha ou não a palavra final escrita.
O compilador recusa a captura de variável que muda:
int contador = 0;
estoque.forEach(p -> {
contador = contador + 1;
});
$ javac Relatorio.java
Relatorio.java:9: error: local variables referenced from a lambda expression must be final or effectively final
contador = contador + 1;
^
1 error
O motivo é o tempo: a lambda pode rodar muito depois, quando o método que a criou já retornou e as variáveis locais dele já morreram com a pilha de chamadas. O que a lambda captura é o valor, congelado; permitir reatribuição criaria duas verdades sobre a mesma variável. Quando a vontade é acumular algo dentro de uma lambda, o desenho certo quase sempre é outro: ou o laço comum de sempre, ou as ferramentas de agregação do capítulo 19, que existem exatamente para isso.
O capítulo 15 fica pago aqui: o teste de recusa, escrito lá com try e
fail, encolhe para a forma moderna, com a lambda segurando o código que
deve explodir e o literal de classe dizendo o tipo esperado:
@Test
void recusaEstoqueNegativo() {
assertThrows(IllegalArgumentException.class,
() -> new Produto("7891000100103", "Café 500g", new BigDecimal("19.90"), -8));
}
A forma longa. Antes das lambdas, versão 8, o mesmo comparador se
escrevia criando no ato uma classe sem nome que implementa a interface, a
classe anônima: new Comparator<Produto>() { public int compare(...) {...} },
cinco linhas de moldura para uma de conteúdo. Código anterior a 2014 está
cheio delas, e o apêndice de legado ensina a lê-las de relance; a lambda não
mudou o mecanismo, mudou a grafia.
Prática
-
Ordene o estoque de quatro jeitos: por nome com a ordem natural, por preço com
comparing, por preço decrescente e por categoria com desempate de nome. Imprima cada versão comforEache referência de método. -
Escreva um método
filtrar(List<Produto> produtos, Predicate<Produto> criterio)que devolva a lista dos aprovados, e use-o três vezes com critérios diferentes, incluindo um capturando um teto de preço. -
Reproduza a armadilha da subtração com os saldos de sinais opostos, mostre a ordem errada, e conserte das duas formas. Escreva o teste de regressão que teria pegado o defeito.
-
Converta os testes de recusa do capítulo 15 para
assertThrows, e escreva um novo:ItemDeVendacom quantidade zero, conferindo também a mensagem, com o objeto exceção queassertThrowsdevolve. -
Fabrique com lambda um
MeioDePagamentode teste que cobre o dobro, use-o noCaixasem criar classe nenhuma, e explique por escrito por que isso funciona à luz da definição de interface funcional.
Ficha do capítulo
| Interface | Método | Papel |
|---|---|---|
Predicate<T> | test: T → boolean | critério; removeIf, filtros |
Function<T, R> | apply: T → R | transformação |
Consumer<T> | accept: T → nada | ação sobre o valor; forEach |
Supplier<T> | get: nada → T | fornecimento sob demanda |
Comparator<T> | compare: T, T → int | ordem; comparing, thenComparing, reversed |
| Termo | Definição |
|---|---|
| lambda | função sem nome escrita como expressão: parâmetros -> corpo |
| interface funcional | interface com um único método abstrato; o alvo de toda lambda |
| referência de método | Tipo::metodo: a lambda que só chamaria um método, sem a cerimônia |
| literal de classe | Tipo.class: o objeto que representa o tipo; reina no capítulo 23 |
| captura de variável | a lambda usando variáveis do escopo onde nasceu |
| efetivamente final | variável nunca reatribuída; condição para ser capturada |
Streams e Optional
O relatório de reposição do mercadinho quer os nomes dos produtos de mercearia com estoque abaixo de dez, em ordem alfabética. Com as coleções e as lambdas dos dois capítulos anteriores, o laço sai assim:
List<String> reposicao = new ArrayList<>();
for (Produto p : estoque) {
if (p.categoria() == Categoria.MERCEARIA && p.estoque() < 10) {
reposicao.add(p.nome());
}
}
reposicao.sort(Comparator.naturalOrder());
Funciona, com Comparator.naturalOrder() devolvendo o comparador da ordem
natural do tipo, e mistura três assuntos numa estrutura só: o critério, a
transformação de produto em nome e a coleta do resultado, todos dentro do
mesmo laço, com uma lista intermediária mutável de apoio. A biblioteca
padrão oferece uma segunda forma de escrever exatamente isso, em que cada
assunto vira uma etapa nomeada:
List<String> reposicao = estoque.stream()
.filter(p -> p.categoria() == Categoria.MERCEARIA)
.filter(p -> p.estoque() < 10)
.map(Produto::nome)
.sorted()
.toList();
O que é um stream
Um stream é um fluxo de valores processado por uma cadeia de operações
declaradas, criado a partir de uma fonte, tipicamente uma coleção, pelo
método stream(). Ele não é uma coleção nova: não guarda elemento nenhum,
não substitui a lista de origem e serve para uma única passagem, do começo
ao fim. A cadeia inteira se chama pipeline, e tem anatomia fixa: uma fonte,
zero ou mais operações intermediárias e exatamente uma operação terminal.
Uma operação intermediária transforma o fluxo e devolve outro stream, o que
permite emendar a próxima: filter deixa passar quem cumpre o critério, um
Predicate; map transforma cada elemento com uma
Function; sorted ordena, com a ordem natural ou com um Comparator;
limit(n) corta o fluxo nos primeiros n. Uma operação terminal encerra o
pipeline produzindo o resultado de fato: toList() coleta numa lista
imutável, count() conta, forEach consome um a um. A leitura do pipeline
é a razão de ele existir: cada linha diz uma coisa, na ordem em que
acontecem, e o critério, a transformação e a coleta pararam de dividir o
mesmo bloco de chaves.
Avaliação preguiçosa
Um pipeline declarado com uma impressão espiã dentro do critério:
void main() {
List<Integer> estoques = List.of(3, 40, 7, 25);
estoques.stream()
.filter(e -> {
IO.println("avaliando " + e);
return e < 10;
});
IO.println("relatório pronto");
}
Quantas linhas “avaliando” aparecem, e onde entra o “relatório pronto”?
relatório pronto
Nenhuma. O pipeline foi declarado e nunca executado, porque não tem operação
terminal: as intermediárias são preguiçosas, e essa é a avaliação
preguiçosa, o mesmo comportamento guardado sem executar do Supplier,
agora em cadeia: declarar as etapas não processa nada, e é a operação terminal que
liga o motor e puxa os elementos através das etapas. Acrescentar .count()
ao pipeline acima faria as quatro linhas “avaliando” aparecerem antes do
“relatório pronto”. As consequências práticas: efeito colateral dentro de
intermediária é promessa de confusão, porque roda quando e se o terminal
mandar; e um pipeline sem terminal é um engano silencioso que o compilador
aceita de bom grado. Stream sem terminal não faz nada.
reduce, e o dinheiro nos streams
Somar é o exemplo canônico de agregação, e agregação em stream tem nome:
reduce combina todos os elementos num único resultado, a partir de um
valor inicial e de uma operação de dois em um:
BigDecimal totalDoDia = vendas.stream()
.map(ItemDeVenda::subtotal)
.reduce(BigDecimal.ZERO, BigDecimal::add);
Lê-se: comece do zero e vá somando. O acumulador que o capítulo 18 proibiu de capturar aparece aqui do jeito certo: o pipeline carrega o acumulado por dentro, sem variável externa mutável.
O mesmo total, escrito por quem viu que existe uma soma pronta para números:
double totalDoDia = vendas.stream()
.mapToDouble(item -> item.subtotal().doubleValue())
.sum();
IO.println(totalDoDia);
Com três vendas de dez centavos, o fechamento imprime:
0.30000000000000004
O atalho converteu cada BigDecimal para double no meio do caminho, e o
problema do capítulo 3 voltou inteiro: um décimo não tem escrita binária
exata, a soma
acumula as sobras, e o caixa fecha com diferença de fração de centavo que
ninguém rastreia. O mapToDouble e o sum existem e servem para medidas;
dinheiro atravessa o pipeline como BigDecimal do começo ao fim, com o
reduce somando pelo método add. É a mesma regra de sempre, cobrada num
lugar novo, e ela cai em teste porque a conversão escondida no meio do
pipeline é fácil de não ver em revisão.
Collectors: agrupar e resumir
toList() é a coleta simples; as coletas com forma vivem na classe
Collectors, uma fábrica de coletores para os resumos que relatórios
pedem. O agrupamento é o mais usado deles:
Map<Categoria, List<Produto>> porCategoria = estoque.stream()
.collect(Collectors.groupingBy(Produto::categoria));
Map<Categoria, Long> contagemPorCategoria = estoque.stream()
.collect(Collectors.groupingBy(Produto::categoria, Collectors.counting()));
groupingBy monta um Map cujas chaves saem da função dada e cujos valores
são os elementos de cada grupo; a segunda forma troca a lista de cada grupo
por outro resumo, aqui a contagem. O relatório de vendas por categoria, que
o capítulo 17 montou com getOrDefault num laço, encolhe para uma
expressão, e as duas versões seguem corretas: o pipeline não aposenta o
laço, dá a ele um concorrente mais legível quando o assunto é transformar e
resumir coleções. Stream e Collectors moram no pacote
java.util.stream; Optional, da seção seguinte, mora em java.util.
Optional: a ausência com tipo
Buscar o produto mais barato da mercearia tem um caso na espreita: a
mercearia pode estar vazia. O stream não devolve null
nem lança erro; devolve o tipo feito para isso:
Optional<Produto> maisBarato = estoque.stream()
.filter(p -> p.categoria() == Categoria.MERCEARIA)
.min(Comparator.comparing(Produto::preco));
Optional<T> é um recipiente de zero ou um valor: a ausência deixa de ser
um null à espreita e vira parte do tipo, que o compilador obriga a
encarar. O que se faz com ele é sempre uma das poucas formas:
Produto escolhido = maisBarato.orElse(produtoPadrao);
Produto exigido = maisBarato.orElseThrow(
() -> new IllegalStateException("Mercearia sem produtos."));
maisBarato.ifPresent(p -> IO.println("Oferta: " + p.nome()));
orElse entrega o valor ou o substituto; orElseThrow entrega ou lança a
exceção do Supplier, para quando a ausência é violação; ifPresent age só
na presença, com um Consumer; e map transforma o conteúdo sem abri-lo,
devolvendo outro Optional. IllegalStateException, prima da
IllegalArgumentException, sinaliza estado inválido em vez de argumento
inválido. Existe também o método get, que lança quando vazio, e
a regra sobre ele é curta: quem usa get sem conferir presença reinventou o
NullPointerException com mais letras.
As convenções de uso valem tanto quanto a classe. Optional nasceu para
tipo de retorno, dizendo “esta busca pode não encontrar”, como o
findFirst, terminal que devolve o primeiro elemento do fluxo, se houver,
e o min dos streams dizem; campo Optional e parâmetro
Optional são desenho torto, porque a pergunta certa nesses lugares é
outra, e a resposta de entrevista resume: Optional documenta ausência
possível no retorno, e o resto continua sendo modelagem.
Streams paralelos. Trocar stream() por parallelStream() divide o
trabalho entre os processadores da máquina, e é tentador como toda linha
única. O custo escondido é o assunto inteiro do capítulo 22: dividir
trabalho cria os problemas de coordenação de lá, e pipeline paralelo com
efeito colateral é receita de resultado errado intermitente. Até lá, e na
maior parte do código real, o stream comum basta.
Prática
-
Reescreva com pipeline os dois relatórios do capítulo 17: contagem de vendas por categoria e a versão agrupada com os produtos de cada uma. Compare linha a linha com as versões de laço.
-
Reproduza a previsão da preguiça, depois acrescente
.count()e anote a ordem exata das impressões. Escreva em uma frase o papel da operação terminal. -
Reproduza a armadilha do
mapToDoublecom três vendas de R$ 0,10, mostre a diferença, e escreva o teste de regressão comreduceecompareToque a impede de voltar. -
Escreva
maisCaroDaCategoria(List<Produto> estoque, Categoria c)devolvendoOptional<Produto>, e trate a ausência das três formas: substituto, exceção e ação condicional. Decida por escrito qual das três o relatório do mercadinho deveria usar. -
Monte o “top 3 mais vendidos”: some as quantidades por produto num
Map, e produza a lista dos três maiores com um pipeline sobreentrySet(), usandosortedcomComparatorelimit.
Ficha do capítulo
| Operação | Tipo | O que faz |
|---|---|---|
filter(Predicate) | intermediária | deixa passar quem cumpre o critério |
map(Function) | intermediária | transforma cada elemento |
sorted() / sorted(Comparator) | intermediária | ordena o fluxo |
limit(n) | intermediária | corta nos primeiros n |
toList() / count() / forEach | terminal | coleta, conta, consome |
reduce(inicial, operação) | terminal | agrega tudo num resultado |
collect(Collectors.groupingBy(...)) | terminal | agrupa num Map, com resumo opcional |
min / max / findFirst | terminal | devolvem Optional |
| Termo | Definição |
|---|---|
| stream | fluxo de valores processado por uma cadeia declarada; uma passagem só |
| operação intermediária | transforma o fluxo e devolve stream; preguiçosa |
| operação terminal | encerra o pipeline e produz o resultado; liga o motor |
| avaliação preguiçosa | nada roda até a terminal puxar os elementos |
Collectors | fábrica de coletas com forma: agrupar, contar, resumir |
reduce | agregação: valor inicial mais operação de dois em um |
Optional | recipiente de zero ou um valor; a ausência como parte do tipo |
java.time: datas sem armadilha
O queijo do mercadinho tem validade, e a pergunta do balcão é diária: quantos
dias faltam? Datas são o território clássico do bug bobo, ano bissexto, mês
de trinta dias, virada de ano, e a biblioteca java.time existe para que
nenhuma dessas contas seja feita à mão:
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
LocalDate hoje = LocalDate.of(2026, 8, 23);
LocalDate validade = LocalDate.of(2026, 9, 30);
long diasRestantes = ChronoUnit.DAYS.between(hoje, validade);
IO.println(diasRestantes);
38
LocalDate: a data civil
LocalDate representa uma data de calendário, dia, mês e ano, sem hora e
sem lugar: a data da etiqueta de validade, do feriado, do vencimento.
Nasce de LocalDate.of(ano, mes, dia), que valida na entrada e recusa 31 de
fevereiro com DateTimeException, uma exceção unchecked, ou de
LocalDate.now(), a data corrente do relógio da máquina. A aritmética vem
por métodos, plusDays, plusMonths, minusWeeks, e as comparações por
isBefore, isAfter e o compareTo de Comparable, que o tipo implementa;
produto vencido é validade.isBefore(hoje), sem conta manual nenhuma.
Todo tipo deste capítulo é imutável, na tradição do String e do
BigDecimal: cada plus devolve um objeto novo e o original permanece. A
consequência já tem cicatriz conhecida no livro, e aqui ela reaparece com
fantasia de data:
LocalDate vencimento = LocalDate.of(2026, 8, 23);
vencimento.plusDays(30);
IO.println(vencimento);
2026-08-23
A segunda linha calculou um vencimento novo e o jogou fora, como o
toUpperCase ignorado do capítulo 5: o fiado continua vencendo hoje, sem
erro nenhum. A forma certa guarda o retorno, vencimento = vencimento.plusDays(30), e a imutabilidade paga o preço da distração com o
mesmo troco de sempre: nenhum outro trecho do sistema vê a data mudar por
baixo dele.
A promoção de aniversário do mercadinho, marcada para “um mês depois” do dia 31 de janeiro:
LocalDate inicio = LocalDate.of(2026, 1, 31);
IO.println(inicio.plusMonths(1));
Fevereiro de 2026 tem 28 dias. O que imprime?
2026-02-28
plusMonths avança o mês e, quando o dia não existe no destino, recua para
o último dia válido, sem lançar erro e sem inventar 3 de março. É uma
decisão documentada da biblioteca, boa para vencimentos e mensalidades, e o
que a linha nem tenta fazer é adivinhar intenção: quem precisa de outra
política, como “primeiro dia do mês seguinte”, escreve a política. A lição
transferível: aritmética de calendário tem casos de borda por natureza, e a
biblioteca os resolve com regras declaradas, não com aproximações.
Period, Duration e as duas perguntas de distância
Entre duas datas existem duas perguntas diferentes. “Quanto tempo civil?”
responde-se com Period, o intervalo em anos, meses e dias:
Period prazo = Period.between(hoje, validade);
IO.println(prazo);
P1M7D
Um mês e sete dias, na notação compacta que o toString de Period usa: o
P abre o período, e cada número carrega sua unidade. “Quantos dias
corridos?” responde-se com ChronoUnit.DAYS.between, como na abertura, que
devolve o total na unidade pedida, 38, sem decompor. Os dois discordam de
propósito, porque medem coisas diferentes, e relatório que mistura os dois
mistura unidades. Para distâncias com relógio, horas, minutos, segundos, o
tipo é Duration, com a mesma notação: Duration.ofMinutes(90) imprime
PT1H30M, uma hora e meia depois do T que separa a parte de tempo.
LocalDateTime, data e hora juntas, é o carimbo dos registros do dia a
dia, a venda das 2026-08-23T14:32, com a mesma aritmética imutável.
Instant, ZoneId e o fuso horário
Nenhum tipo acima diz onde. LocalDateTime é anotação de calendário sem
localização: “23 de agosto, 14:32” acontece em momentos físicos diferentes
em São Paulo e em Tóquio, e para dinheiro, auditoria e sincronização isso
importa. O tipo do momento físico é o Instant: o ponto na linha do tempo
global, único para o planeta inteiro, sem calendário e sem opinião sobre
como humanos o chamam. Instant.now() é o agora, o mesmo agora em qualquer
máquina do mundo.
A tradução entre o momento físico e o calendário humano depende do fuso
horário: a regra regional que diz que horas são em cada lugar, representada
por ZoneId:
import java.time.Instant;
import java.time.ZoneId;
Instant momentoDaVenda = Instant.now();
ZoneId saoPaulo = ZoneId.of("America/Sao_Paulo");
IO.println(momentoDaVenda.atZone(saoPaulo).toLocalDateTime());
O atZone aplica o fuso e revela o calendário local daquele instante. A
regra de arquitetura que essa distinção sustenta é curta e cai em
entrevista: registra-se o momento como Instant, e converte-se para data e
hora locais só na hora de mostrar a alguém, com o fuso de quem olha. Um
sistema que grava LocalDateTime sem anotar o fuso grava um registro
ambíguo, e a ambiguidade cobra quando o servidor muda de país, quando dois
sistemas se integram, ou na próxima mudança de regra de horário, que
governos fazem quando querem.
DateTimeFormatter: mostrar e ler
O toString dos tipos usa o formato internacional, ano-mês-dia, ótimo para
registro e ordenável como texto. O balcão fala outra língua, e o
DateTimeFormatter traduz nas duas direções:
import java.time.format.DateTimeFormatter;
DateTimeFormatter brasileiro = DateTimeFormatter.ofPattern("dd/MM/yyyy");
IO.println(validade.format(brasileiro));
LocalDate lida = LocalDate.parse("30/09/2026", brasileiro);
O padrão usa letras com papel fixo: dd dia, MM mês, yyyy ano. A caixa
das letras importa, e a dupla MM maiúsculo mês contra mm minúsculo
minuto é a confusão frequente; a do YYYY, logo adiante, é pior, porque
passa meses sem sintoma:
Um cupom com a data formatada por quem lembrou do padrão “quase certo”:
DateTimeFormatter doCupom = DateTimeFormatter.ofPattern("dd/MM/YYYY");
IO.println(LocalDate.of(2025, 12, 31).format(doCupom));
31/12/2026
A data é de 2025, e o cupom imprime 2026.
YYYY maiúsculo não é o ano do calendário: é o ano da semana, uma convenção
em que os últimos dias de dezembro podem pertencer à primeira semana do ano
seguinte, e 31 de dezembro de 2025 pertence. O formato funciona idêntico ao
yyyy o ano inteiro e erra exatamente na virada do ano. A regra: ano de calendário é yyyy
minúsculo, e YYYY só existe para quem trabalha deliberadamente com
semanas. É o tipo de defeito que o teste de regressão do capítulo 15 fixa
para sempre com uma data de dezembro.
O relógio injetável. LocalDate.now() dentro de uma regra de negócio é
o Random sem semente do capítulo 6: cada execução, um valor, e o teste do
“produto vence em três dias” passa hoje e falha na semana que vem. A
biblioteca prevê a saída: as versões de now aceitam um Clock, um relógio
substituível, e o teste entrega um relógio congelado num dia escolhido. O
capítulo 24 dá o nome geral dessa manobra.
Prática
-
Acrescente validade ao produto perecível do mercadinho:
LocalDateno construtor, um métodovencido(LocalDate hoje)e umdiasParaVencer(LocalDate hoje). Recuse validade nula com a exceção de sempre. -
Escreva o relatório “vence em até sete dias” com um pipeline do capítulo 19: filtrar por proximidade da validade, ordenar pela data, mapear para nome e dias restantes.
-
Reproduza a previsão do 31 de janeiro para os doze meses de 2026 num laço, imprimindo cada resultado, e marque quais meses recuaram o dia.
-
Reproduza a armadilha do
YYYYcom cinco datas de dezembro e janeiro, identifique exatamente quais erram, e escreva o teste de regressão que trava o formato certo. -
Registre uma venda com
Instant.now(), mostre-a no fuso de São Paulo e no de Tóquio com o mesmo formatador de data e hora, e explique por escrito por que oInstantgravado é um só. -
Calcule o vencimento do fiado: trinta dias corridos após a compra, e também “mesmo dia do mês seguinte”, comparando as duas políticas para uma compra feita em 31 de janeiro.
Ficha do capítulo
| Tipo | Representa | Nasce de |
|---|---|---|
LocalDate | data civil, sem hora e sem lugar | of(ano, mes, dia), now(), parse |
LocalDateTime | data e hora, sem lugar | of(...), now() |
Instant | o momento físico global | Instant.now() |
Period | distância em anos, meses e dias | Period.between(a, b) |
Duration | distância com relógio | Duration.ofMinutes(90) |
ZoneId | o fuso horário | ZoneId.of("America/Sao_Paulo") |
| Termo | Definição |
|---|---|
| fuso horário | a regra regional que traduz momento físico em hora local |
ChronoUnit | distância total numa unidade só: DAYS.between(a, b) |
DateTimeFormatter | formata e interpreta datas: ofPattern("dd/MM/yyyy") |
| imutabilidade | todo cálculo devolve objeto novo; guardar o retorno é obrigatório |
| Regra prática | |
|---|---|
| registrar momento | Instant; converter para local só na exibição |
| ano no formato | yyyy minúsculo; YYYY é ano de semana e erra na virada |
now() em regra de negócio | pede relógio injetável para o teste mandar no tempo |
I/O, NIO.2, argumentos e jar executável
O mercadinho fecha, o processo termina, e todo o estoque montado em memória morre junto: na manhã seguinte, o sistema nasce vazio de novo. Falta a última peça de um sistema de verdade, guardar e recuperar dados em arquivo:
void main() throws IOException {
Path arquivo = Path.of("estoque.txt");
List<String> linhas = List.of(
"7891000100103;Café 500g;19.90;25",
"7891000200100;Queijo minas;39.80;8"
);
Files.write(arquivo, linhas);
List<String> lidas = Files.readAllLines(arquivo);
IO.println(lidas.size() + " produtos no arquivo");
}
$ java Persistencia.java
2 produtos no arquivo
No arquivo-fonte compacto, nada disso pede import; na moldura completa,
Path e Files vêm de java.nio.file e IOException vem de java.io.
Duas classes fazem quase todo o trabalho de arquivo do dia a dia. Path é o
endereço: representa um caminho no disco, sem tocar nele, criado por
Path.of. Files é o operário: a classe de métodos estáticos que age no
endereço, escrevendo, lendo, conferindo exists, criando pastas com
createDirectories, apagando. Ao lado de readAllLines,
Files.readString devolve o conteúdo inteiro numa única String, o par
para arquivo pequeno lido de uma vez. As duas classes pertencem ao pacote
java.nio.file, apelidado NIO.2, a geração atual da biblioteca de
arquivos; a geração anterior aparece no apêndice de legado.
E a assinatura do main carrega a novidade prometida no capítulo 13: quase
tudo em Files lança IOException, a exceção checked do mundo externo,
porque disco cheio, permissão negada e arquivo sumido não são bugs do
programa, são respostas possíveis do ambiente. O compilador cobra a
escolha de sempre: tratar com catch, para o mercadinho reagir, ou declarar
com throws, para a decisão subir. O main de laboratório declara; o
sistema de verdade trata, e a prática deste capítulo faz as duas versões.
O caminho relativo e o diretório de trabalho
O programa acima rodou dentro da pasta do projeto e funcionou. No dia seguinte, o operador o executa da pasta de cima:
$ java mercadinho/Persistencia.java
O estoque.txt continua onde sempre esteve, dentro de mercadinho/. O que
acontece?
$ java mercadinho/Persistencia.java
2 produtos no arquivo
A saída é idêntica à de ontem, e a pasta de cima amanhece com um
estoque.txt novo. Path.of("estoque.txt") é um caminho relativo: ele se
resolve contra o diretório de trabalho, a pasta onde o comando foi
digitado, não a pasta onde o programa mora. Como o programa escreve antes
de ler, o Files.write criou o arquivo no lugar errado sem reclamar, e o
readAllLines leu esse recém-nascido; o estoque verdadeiro, dentro de
mercadinho/, nunca foi tocado, e a partir daqui existem dois arquivos
divergindo em silêncio. Um programa que só lesse cairia com
NoSuchFileException, uma descendente de IOException com nome honesto, e
a queda seria o desfecho bom: erro visível em vez de dado duplicado. É o
irmão gêmeo do classpath: “não encontrou”, e também “criou onde não devia”,
quase nunca significa que o arquivo não existe, significa que o programa
partiu de outro lugar. As defesas do dia a dia: imprimir
arquivo.toAbsolutePath() na dúvida, e deixar o caminho vir de fora, que é
a deixa dos argumentos adiante.
Charset: onde bytes viram texto
Arquivo não guarda texto: guarda bytes, e a tabela que traduz caracteres
em bytes chama-se charset. O charset dominante é o UTF-8, capaz de
codificar toda a tabela Unicode, e os métodos de
Files o usam por padrão nas duas direções, escrever e ler. O defeito
clássico dessa camada é a discordância: texto gravado com um charset e lido
com outro transforma acento em sujeira, o “Café” vindo de fora que aparece
como “Café” no relatório. Com Files dos dois lados não há discordância;
ao receber arquivo de sistemas alheios, planilhas e afins, o charset da
origem é a primeira pergunta a fazer, e as versões dos métodos que recebem
um Charset explícito, como StandardCharsets.UTF_8, deixam a escolha
escrita no código.
try-with-resources
Ler o arquivo inteiro com readAllLines serve até o arquivo crescer; a
leitura linha a linha usa um leitor aberto, e tudo que se abre no sistema
operacional precisa ser fechado, com exceção ou sem. O capítulo 13 prometeu
a escrita dedicada para isso:
try (var leitor = Files.newBufferedReader(arquivo)) {
String linha;
while ((linha = leitor.readLine()) != null) {
processar(linha);
}
}
O try-with-resources declara o recurso entre parênteses e garante o
fechamento ao sair do bloco, por qualquer porta: fim normal, return ou
exceção subindo. O contrato por trás é a interface AutoCloseable, de um
método só, close; tudo que a implementa pode morar nesses parênteses, e
tudo que representa recurso do sistema a implementa. A regra é curta: todo
recurso nasce dentro de um try-with-resources, e o finally de fechamento
vira peça de museu, lida em código antigo e não
escrita em código novo. O readLine devolvendo null no fim do arquivo é a
convenção dessa família de leitores, um dos reencontros legítimos com o
null.
O estoque em arquivo usa ponto e vírgula como separador, e o fornecedor novo cadastra o produto com capricho:
Produto novo = new Produto("7891000300107", "Açúcar; cristal 5kg", new BigDecimal("21.50"), 30);
gravarNoEstoque(novo);
A gravação corre sem erro. O que acontece quando o sistema carregar o arquivo na manhã seguinte?
A carga cai com NumberFormatException: o ponto e vírgula dentro do nome
virou separador, a linha ganhou uma coluna a mais, e a conversão de preço
recebeu “ cristal 5kg“. A queda acontece longe da causa, no dia seguinte,
com o arquivo já corrompido no disco; e ela é o desfecho bom, porque
bastaria o deslocamento cair numa coluna de texto para a linha carregar em
silêncio, com os dados trocados e ninguém avisado. Dado corrompido em
silêncio é o pior resultado de uma gravação. As saídas honestas, em ordem
de esforço: proibir o separador no dado, com a invariante do construtor
recusando ; no nome; escapar o separador na escrita e desfazer na
leitura; ou adotar um formato com biblioteca pronta, o caminho de sistemas
maiores, fora do escopo daqui. O mercadinho adota a primeira, uma linha no
construtor, e um teste de regressão a vigia.
Argumentos de linha de comando, de novo
O String[] args da moldura completa do main fecha o circuito da
previsão do caminho relativo: o arquivo do estoque não precisa estar cravado no fonte.
public static void main(String[] args) throws IOException {
Path arquivo = args.length > 0 ? Path.of(args[0]) : Path.of("estoque.txt");
// carrega e segue
}
$ java -jar mercadinho.jar /dados/mercadinho/estoque.txt
Argumento presente escolhe o arquivo, ausente cai no padrão, e o operador decide na execução, sem recompilar. É o suficiente para o mercadinho; sistemas com muitas opções adotam bibliotecas de linha de comando, mais uma fronteira anotada e não cruzada.
O jar executável
A linha acima usou java -jar, e falta pagá-la. O jar do capítulo 14 era
uma caixa de bytecode para o classpath dos outros; o jar executável é a
mesma caixa sabendo por onde começar. Quem guarda essa informação é o
manifest: o arquivo de metadados que vive dentro de todo jar, em
META-INF/MANIFEST.MF, pares de chave e valor sobre o pacote; a chave
Main-Class aponta a classe cujo main o java -jar deve chamar. No
Maven, isso é configuração do plugin que empacota:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<archive>
<manifest>
<mainClass>Loja</mainClass>
</manifest>
</archive>
</configuration>
</plugin>
$ mvn package
$ java -jar target/mercadinho-1.0.jar
O nome em mainClass é o da classe com o main; quando o projeto ganhar
pacotes, no capítulo 25, ele passa a ser o nome qualificado. Dois comandos
separam o fonte de um arquivo único que roda em qualquer máquina com JDK: é
a promessa de distribuição da JVM embrulhada para entrega. O projeto integrador fecha nesse formato.
Arquivo como stream. Files.lines(arquivo) devolve as linhas como um
stream preguiçoso: as linhas são lidas do disco conforme a
operação terminal puxa. O detalhe que pega: esse stream segura o arquivo
aberto, é um recurso, e mora num try-with-resources como qualquer leitor;
é o único stream comum do livro com essa exigência.
Prática
-
Escreva
RepositorioDeEstoqueEmArquivocom dois métodos:salvar(List<Produto>)ecarregar(), no formato de quatro colunas, com try-with-resources na leitura linha a linha eIOExceptiontratada com mensagem digna para o operador. -
Reproduza a previsão do caminho relativo executando de duas pastas diferentes, conserte com argumento de linha de comando, e imprima o caminho absoluto no início do programa como diagnóstico permanente.
-
Reproduza a armadilha do separador, mostre o produto corrompido, e aplique a defesa da invariante:
Produtorecusa;no nome. Escreva o teste de regressão comassertThrows. -
Acrescente ao repositório a criação da pasta de dados quando não existir, e o comportamento de primeiro uso: arquivo ausente devolve estoque vazio em vez de cair. Decida se
carregardevolve lista ouOptionale justifique. -
Configure o jar executável, empacote, e rode o mercadinho de fora da pasta do projeto, passando o caminho do estoque por argumento. Confira o manifest gerado com
unzip -p target/mercadinho-1.0.jar META-INF/MANIFEST.MF.
Ficha do capítulo
| Chamada | O que faz |
|---|---|
Path.of("...") | o endereço; não toca o disco |
Files.write / readAllLines / readString | escrita e leitura inteiras, UTF-8 |
Files.exists / createDirectories | conferência e criação de pastas |
Files.newBufferedReader | leitor linha a linha; recurso a fechar |
Files.lines | as linhas como stream preguiçoso; recurso a fechar |
java -jar arquivo.jar | executa o jar pela Main-Class do manifest |
| Termo | Definição |
|---|---|
Path | um caminho no disco, absoluto ou relativo ao diretório de trabalho |
Files | as operações de arquivo, em métodos estáticos |
IOException | a checked do mundo externo: disco, permissão, ausência |
| charset | a tabela que traduz caracteres em bytes; UTF-8 é o padrão |
| try-with-resources | recurso declarado no try, fechado por qualquer saída |
AutoCloseable | o contrato de um método close; habilita o try-with-resources |
| manifest | os metadados dentro do jar; Main-Class define a entrada |
| jar executável | jar com Main-Class, executado por java -jar |
Concorrência: executors e virtual threads
O fechamento do mês do mercadinho lê trinta arquivos de vendas do capítulo 21, um por dia, e soma tudo. A leitura é sequencial e demora; a máquina tem oito processadores e usa um. A tentação é óbvia, dividir o trabalho, e este capítulo existe porque a tentação, executada sem as regras, produz fechamento errado sem erro nenhum. Antes do desastre, a peça nova.
Thread: a segunda linha de execução
Todo programa deste livro até aqui executou numa linha só: uma instrução
por vez, uma pilha de chamadas, do main ao fim. Uma thread é uma linha de
execução independente dentro do mesmo processo: tem a própria pilha de
chamadas e o próprio “onde estou”, e compartilha com as outras o heap, os
objetos do capítulo 5. Criar uma segunda é pedir à JVM que execute um
trecho, um Runnable, a interface funcional de um método run sem
argumentos e sem retorno, em paralelo com quem pediu:
O fechamento divide o mês em duas quinzenas, uma thread para cada, e o
main espera as duas com join:
void main() throws InterruptedException {
Thread primeira = new Thread(() -> {
for (int i = 1; i <= 3; i++) {
IO.println("quinzena 1, arquivo " + i);
}
});
Thread segunda = new Thread(() -> {
for (int i = 1; i <= 3; i++) {
IO.println("quinzena 2, arquivo " + i);
}
});
primeira.start();
segunda.start();
primeira.join();
segunda.join();
IO.println("fechado");
}
Em que ordem saem as seis linhas?
Em ordem nenhuma garantida, e essa é a resposta certa: numa execução as
seis podem sair intercaladas, noutra a quinzena 1 inteira antes da
quinzena 2, e duas execuções seguidas podem diferir. Quem decide qual
thread avança a cada momento é o escalonador do sistema, e o programa
correto sob concorrência é o que está certo em todas as intercalações
possíveis, não o que deu certo na intercalação de hoje. Só o “fechado” tem
posição prometida, porque join faz o main esperar a thread terminar;
join declara InterruptedException, a exceção checked da espera
interrompida, que os main de laboratório repassam com throws. start
dispara;
run chamado direto seria um método comum na mesma linha de execução, sem
paralelismo nenhum, um clássico de primeira semana.
A condição de corrida
O fechamento conta os itens vendidos no mês num contador compartilhado: duas tarefas, cada uma varrendo metade dos arquivos e registrando cem mil itens:
class Contador {
int total = 0;
void incrementar() {
total++;
}
}
void main() throws InterruptedException {
Contador itens = new Contador();
Runnable tarefa = () -> {
for (int i = 0; i < 100_000; i++) {
itens.incrementar();
}
};
Thread primeiraMetade = new Thread(tarefa);
Thread segundaMetade = new Thread(tarefa);
primeiraMetade.start();
segundaMetade.start();
primeiraMetade.join();
segundaMetade.join();
IO.println(itens.total);
}
Duzentos mil itens registrados. Três execuções reais imprimiram:
127572
114925
120697
Nem duzentas mil, nem duas vezes o mesmo número. Isso é uma condição de
corrida: duas ou mais threads acessando o mesmo dado, com pelo menos uma
escrevendo, e o resultado dependendo da intercalação. A raiz é a
atomicidade, ou a falta dela: total++ parece uma operação e são três,
ler o valor, somar um, gravar de volta. Quando as duas threads leem o mesmo
total antes de qualquer uma gravar, as duas gravam o mesmo resultado e um
incremento evapora; em cem mil voltas isso acontece dezenas de milhares de
vezes, ao acaso. Nenhuma exceção, nenhum aviso, um total diferente por
execução: é o bug silencioso levado à perfeição, e a versão dele com
dinheiro se chama fechamento que não bate.
synchronized
A correção clássica torna a operação indivisível:
class Contador {
private int total = 0;
synchronized void incrementar() {
total++;
}
synchronized int total() {
return total;
}
}
O modificador synchronized pendura no método uma tranca: cada objeto tem
uma, só uma thread por vez a segura, e as demais esperam na porta. Com a
tranca, o ler-somar-gravar acontece inteiro antes de a próxima thread
entrar, e o programa imprime 200000 em toda execução. O preço está no
próprio desenho: dentro do trecho trancado, o paralelismo deixa de existir,
e trancar demais devolve o programa lento que motivou a divisão. A tranca
protege o dado; o desenho decide onde ela é inevitável.
Visibilidade e volatile
A corrida não é o único fantasma. Cada processador guarda cópias locais do que lê, e uma thread pode não enxergar o que outra escreveu:
class Fechamento {
boolean aberto = true;
void rodar() {
while (aberto) {
// registra vendas
}
IO.println("caixa encerrado");
}
}
Outra thread escreve aberto = false e o laço pode nunca parar: a thread do
laço, otimizada pela JVM, segue lendo a cópia velha do campo, e o
“encerrado” não sai nunca. Esse é o problema da visibilidade entre threads:
sem sinalização explícita, não há garantia de quando uma escrita feita por
uma thread aparece para as outras. O modificador volatile no campo,
volatile boolean aberto, é a sinalização mínima: toda leitura vê a última
escrita, e o laço para. O que o volatile não faz é tanto quanto o que
faz: ele garante visibilidade, não atomicidade, e um volatile int total
com total++ continua perdendo incrementos na corrida da seção anterior. A
tabela mental: synchronized para operações compostas sobre dado
compartilhado; volatile para bandeiras simples que uma thread escreve e
outras leem.
ExecutorService, Future e o desenho que evita tudo isso
Criar threads à mão espalha new Thread pelo sistema; o executor centraliza.
Um ExecutorService é um serviço que recebe tarefas e as executa num
conjunto de threads que ele administra, e um Future é o recibo de uma
tarefa entregue: a promessa de um resultado que ainda não existe, resgatada
com get, que espera se preciso. O fechamento do mês, na forma correta:
try (ExecutorService executor = Executors.newFixedThreadPool(8)) {
List<Future<BigDecimal>> recibos = new ArrayList<>();
for (Path arquivo : arquivosDoMes) {
recibos.add(executor.submit(() -> totalDoArquivo(arquivo)));
}
BigDecimal totalDoMes = BigDecimal.ZERO;
for (Future<BigDecimal> recibo : recibos) {
totalDoMes = totalDoMes.add(recibo.get());
}
IO.println("Fechamento: R$ " + totalDoMes);
}
O desenho importa mais que a ferramenta: cada tarefa lê o próprio arquivo e
devolve o próprio subtotal, sem tocar em nada compartilhado, e o main
combina os resultados sozinho, em sequência. Não há corrida porque não há
dado disputado: as threads trabalham em confinamento e se comunicam por
valores imutáveis de retorno, BigDecimal do capítulo 7, e essa é a
primeira regra do código concorrente que envelhece bem, não compartilhe;
combine. synchronized e volatile ficam para quando o compartilhamento é
inevitável. O executor entra num try-with-resources porque encerrar o
serviço é fechamento de recurso como os do capítulo 21, e submit aceita
tarefas que devolvem valor e lançam exceção. O get do Future declara
duas exceções checked: a InterruptedException da espera e a
ExecutionException, que embrulha a falha da tarefa e a entrega a quem
resgatou o recibo.
Virtual threads
A thread da seção anterior, dita de plataforma, é um recurso caro do sistema operacional: milhares delas esgotam a máquina. A virtual thread é a thread barata da JVM: milhões podem existir, porque quando uma bloqueia esperando o mundo externo, a JVM a tira do processador e o empresta a outra. O executor muda numa linha:
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
// uma tarefa por trabalho, sem contar quantas
}
A regra de escolha fecha o capítulo. Trabalho que espera, arquivos, rede, banco de dados, é o habitat da virtual thread, uma por tarefa, sem pool e sem contagem; trabalho que calcula sem esperar ganha pouco além do número de processadores, e o pool fixo continua certo. É esta segunda peça que explica onde a concorrência vive de verdade: um programa de terminal como o do livro tem um usuário e paraleliza por dentro quando quer velocidade, mas um servidor atende milhares de pedidos simultâneos, cada um numa thread, e todo o vocabulário deste capítulo, corrida, visibilidade, confinamento, é o idioma nativo de lá. O leitor que seguir para esse mundo vai reconhecer cada palavra.
A caixa de ferramentas pronta. O pacote java.util.concurrent tem
peças prontas para os padrões comuns: AtomicInteger incrementa sem tranca
e sem corrida, coleções concorrentes substituem HashMap compartilhado, e
filas conectam threads produtoras a consumidoras. A regra de quem sabe o
básico deste capítulo: antes de escrever synchronized, procurar a peça
pronta que já resolve o padrão.
Prática
-
Reproduza a previsão da intercalação cinco vezes e cole duas saídas diferentes lado a lado. Depois remova os
joine explique o que muda no “fechado”. -
Reproduza a corrida do contador dez vezes, anotando os totais. Conserte com
synchronized, rode dez vezes de novo, e meça comSystem.nanoTimeo preço da tranca nas duas versões. -
Reproduza o laço que não enxerga a bandeira, conserte com
volatile, e depois prove no contador quevolatilenão conserta corrida: troquesynchronizedporvolatilee mostre os totais errados. -
Implemente o fechamento do mês com
ExecutorServicesobre os arquivos do capítulo 21: gere trinta arquivos de vendas de teste, some em paralelo com confinamento e combine comBigDecimal. Confira contra a soma sequencial. -
Troque o pool fixo por virtual threads no fechamento e descreva o que mudou e o que não mudou. Escreva em um parágrafo qual dos dois executores você usaria para: somar arquivos locais; consultar trezentos fornecedores pela rede.
Ficha do capítulo
| Peça | O que faz |
|---|---|
new Thread(runnable) / start / join | cria, dispara e espera uma thread |
synchronized | tranca por objeto: um método trancado por vez |
volatile | visibilidade da escrita; não dá atomicidade |
ExecutorService / submit | serviço de execução; recebe tarefas |
Future / get | o recibo da tarefa; espera e devolve o resultado |
Executors.newFixedThreadPool(n) | pool de tamanho fixo, para trabalho de cálculo |
Executors.newVirtualThreadPerTaskExecutor() | uma virtual thread por tarefa, para trabalho que espera |
| Termo | Definição |
|---|---|
| thread | linha de execução com pilha própria, partilhando o heap |
| condição de corrida | resultado dependente da intercalação de acessos, com escrita |
| atomicidade | operação indivisível; total++ não é |
| visibilidade entre threads | garantia de que uma escrita apareça para as outras |
| virtual thread | thread barata da JVM; milhões, para tarefas que esperam |
| Regra prática | |
|---|---|
| primeiro desenho | não compartilhe; confine e combine resultados imutáveis |
| compartilhou | synchronized para operação composta; volatile para bandeira |
| antes de trancar à mão | procurar a peça pronta em java.util.concurrent |
Annotations, reflection e proxies
O capítulo 15 deixou uma pergunta armada: nenhuma linha do mercadinho chama
CaixaTest, e os testes rodam. Alguém encontra as classes de teste,
descobre quais métodos carregam @Test e os executa, sem conhecer nenhum
deles de antemão. Este capítulo constrói esse alguém, com as vinte linhas
prometidas, e no caminho apresenta o mecanismo por trás das ferramentas que
examinam e executam código alheio: executores de teste, mapeadores,
containers.
Anotação própria e retenção
Anotações existem no livro desde o @Override, sempre de prateleira.
Declarar uma é curto:
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@Retention(RetentionPolicy.RUNTIME)
public @interface MeuTeste { }
O @interface declara a anotação, e o corpo vazio diz que ela é pura
marca, sem dados. Anotações também carregam dados, declarados como métodos
no corpo: um String nome(); ali dentro permitiria @MeuTeste(nome = "desconto do dinheiro"), e o leitor da anotação recupera o valor em
execução. O @SuppressWarnings("unchecked") usa essa forma com um atalho:
quando o único elemento se chama value, o nome pode ser omitido no uso;
com qualquer outro nome, como nome, ele é obrigatório, e @MeuTeste("x")
nem compila. A linha de cima é a decisão que importa aqui: a retenção define até
onde a anotação sobrevive. SOURCE vive só no fonte, para o compilador e
ferramentas de fonte, o destino do @Override; CLASS chega ao bytecode e
para lá; RUNTIME chega à execução, visível para o mecanismo da próxima
seção. Uma anotação que pretende ser lida por um executor, como @Test e
como a nossa, precisa de RUNTIME declarado.
Reflexão
Reflexão é a capacidade de um programa examinar e manipular os próprios
tipos em execução: perguntar a uma classe quais métodos ela tem, que
anotações carrega, e invocá-los, tudo por objetos que representam essas
coisas. A porta de entrada é o Class, o objeto que representa um tipo,
obtido pelo literal de classe, CaixaTest.class, ou pelo
getClass de qualquer objeto. Dele saem os representantes dos membros:
Method para métodos, Field para campos e Constructor para
construtores, cada um sabendo o próprio nome, os tipos envolvidos e as
anotações que carrega; do Class também saem a superclasse e as interfaces
implementadas, a hierarquia inteira disponível como dado. É o
programa lendo a própria estrutura, e um encontro com isso o livro já teve
sem dizer o nome: o Produto@6f2b958e é o getClass por
trás do toString herdado, imprimindo o nome do tipo real.
O laboratório deste capítulo usa a moldura completa, cada classe em seu
próprio arquivo: em arquivo-fonte compacto as classes viram aninhadas, e o
executor adiante não as encontraria pelo caminho mostrado. O alvo é uma
CaixaTest sem JUnit nenhum, com a asserção escrita à mão para o
laboratório rodar com java puro, sem classpath de biblioteca:
import java.math.BigDecimal;
public class CaixaTest {
@MeuTeste
public void somaDuasVendas() {
BigDecimal total = new BigDecimal("19.90").add(new BigDecimal("19.90"));
if (!total.equals(new BigDecimal("39.80"))) {
throw new AssertionError("esperado: <39.80> mas foi: <" + total + ">");
}
}
@MeuTeste
public void descontoDoDinheiro() {
BigDecimal recebido = new BigDecimal("100.00").multiply(new BigDecimal("0.95"));
if (!recebido.equals(new BigDecimal("95.00"))) {
throw new AssertionError("esperado: <95.00> mas foi: <" + recebido + ">");
}
}
}
O if com throw new AssertionError(...), o erro que representa uma
asserção violada, faz à mão o papel do assertEquals, e o segundo teste
reencontra de propósito a escala do BigDecimal: 100.00 vezes 0.95 dá
95.0000, e equals distingue as escalas. O executor prometido cabe
inteiro numa tela:
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
public class Executor {
public static void main(String[] args) throws Exception {
int passaram = 0;
int falharam = 0;
Class<?> tipo = CaixaTest.class;
for (Method metodo : tipo.getDeclaredMethods()) {
if (metodo.isAnnotationPresent(MeuTeste.class)) {
Object instancia = tipo.getDeclaredConstructor().newInstance();
try {
metodo.invoke(instancia);
IO.println("PASSOU " + metodo.getName());
passaram++;
} catch (InvocationTargetException erro) {
IO.println("FALHOU " + metodo.getName() + ": " + erro.getCause().getMessage());
falharam++;
}
}
}
IO.println(passaram + " passaram, " + falharam + " falharam");
}
}
$ javac *.java
$ java Executor
PASSOU somaDuasVendas
FALHOU descontoDoDinheiro: esperado: <95.00> mas foi: <95.0000>
1 passaram, 1 falharam
A leitura, linha a linha do que é novo. getDeclaredMethods devolve os
métodos declarados na classe, em ordem não prometida, e
isAnnotationPresent filtra os marcados, recebendo o literal de classe da
anotação. getDeclaredConstructor().newInstance() cria a instância pelo
construtor sem argumentos, uma por teste, que é exatamente a fixture nova
do JUnit, agora explicada. O invoke chama o método sobre a
instância; quando o método lança, a exceção chega embrulhada numa
InvocationTargetException, e o getCause desembrulha a original, a
AssertionError do if da CaixaTest. Vinte linhas, e o @Test, o
placar e a fixture do JUnit deixaram de ser mágica: a ferramenta real tem
dez anos de recursos em volta, e este núcleo no centro.
O Class<?>, de passagem, usa o wildcard no lugar honesto:
um tipo desconhecido, porque o executor serve para qualquer classe.
O executor pronto, a anotação declarada, e uma distração de uma linha:
public @interface MeuTeste { }
$ javac *.java
$ java Executor
0 passaram, 0 falharam
Os dois testes continuam no arquivo, compilando, marcados. Nenhum roda.
Sem @Retention declarada, a retenção padrão é CLASS: a anotação chega ao
bytecode e é invisível em execução, e isAnnotationPresent responde false
para todos, sem erro, sem aviso. O placar zerado é a única pista, e é por
isso que a armadilha do teste que não roda tem uma prima
aqui: ferramenta baseada em anotação falha em silêncio quando a retenção
está errada, e conferir o placar continua sendo a defesa.
A reflexão cobra dois preços que definem onde usá-la. O primeiro é o
contrato por texto: getDeclaredConstructor() e companhia procuram por
nomes e formas em execução, e o compilador não confere nada, de modo que
renomear um construtor ou mudar uma assinatura quebra o código reflexivo só
quando ele rodar, a categoria de erro que o livro inteiro empurra para a
compilação. O segundo é que ela atravessa muros: com setAccessible,
código reflexivo lê e escreve até membros private, e o encapsulamento
vale contra código comum, não contra ferramentas decididas. A
regra de uso sai dos dois preços: reflexão é técnica de ferramenta e de
infraestrutura, executores, mapeadores, injetores, e não de regra de
negócio; o estoque do mercadinho nunca precisa dela.
Proxy dinâmico
A segunda metade do arsenal fabrica objetos. Um proxy dinâmico é um objeto
criado em execução que implementa interfaces escolhidas e entrega toda
chamada recebida a um único ponto, o InvocationHandler: uma interface
funcional cujo método recebe o proxy, o Method chamado e os argumentos, e
decide o que fazer. O mercadinho quer auditoria de tudo que passa pelo
pagamento, sem tocar nas classes de pagamento:
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
MeioDePagamento dinheiro = compra -> compra.multiply(new BigDecimal("0.95"));
InvocationHandler auditoria = (proxy, metodo, argumentos) -> {
IO.println("[auditoria] " + metodo.getName() + " com " + argumentos[0]);
return metodo.invoke(dinheiro, argumentos);
};
MeioDePagamento vigiado = (MeioDePagamento) Proxy.newProxyInstance(
MeioDePagamento.class.getClassLoader(),
new Class<?>[] { MeioDePagamento.class },
auditoria);
IO.println(vigiado.valorFinal(new BigDecimal("100.00")));
IO.println(vigiado.getClass().getName());
[auditoria] valorFinal com 100.00
95.0000
$Proxy0
O vigiado é um MeioDePagamento legítimo: o caixa o aceita
sem saber de nada, o polimorfismo funciona, e cada chamada passa pela
auditoria antes de ser delegada ao objeto real pelo invoke. A última
linha mostra a certidão de nascimento: $Proxy0, uma classe que não existe
em arquivo nenhum, fabricada pela JVM na hora. O nome sai sem pacote porque
a interface do laboratório vive sem pacote; num projeto com pacotes, como o
da prática, a mesma linha imprime o nome qualificado, jdk.proxy1.$Proxy0. O padrão, interceptar
chamadas de uma interface sem alterar as implementações, é o mecanismo com
que frameworks, as bibliotecas que invertem o controle e chamam o código de
quem as usa, adicionam auditoria, medição de tempo e controle de acesso
em volta de código alheio; o capítulo 24 usa a ideia por um ângulo mais
simples, e quem encontrar um $Proxy num stack trace de framework agora
sabe o que está olhando. A dupla deste capítulo fecha um circuito que vale
enxergar de uma vez: anotação marca a intenção no código, reflexão encontra
a marca e age, e o proxy embrulha o resultado quando o agir é interceptar.
É a receita do executor deste capítulo, e a das ferramentas dessa família
que o leitor vai encontrar depois deste livro, e ela cabe numa tela de
cada vez.
O custo. Chamada reflexiva custa múltiplas vezes uma chamada direta, entre validações e embrulhos, e proxy soma uma indireção a cada método. Para executor de testes e montagem de sistema, irrelevante; para o método quente de um laço, proibitivo. Ferramentas sérias geram bytecode ou usam variantes otimizadas depois da primeira chamada, e a regra do usuário fica a mesma: reflexão na borda do sistema, nunca no miolo do cálculo.
Prática
-
Monte o executor completo num projeto Maven: a anotação, duas classes de teste com métodos que passam e falham, e o placar. Depois remova o
@Retentione reproduza a armadilha. -
Estenda o executor com
@AntesDeCada: um método assim anotado roda antes de cada teste da classe, na mesma instância. Compare com o@BeforeEachdo capítulo 15. -
Escreva um método
descrever(Class<?> tipo)que imprima os métodos públicos de qualquer classe com seus tipos de retorno, e aponte-o paraProdutoe paraString. Anote o que a saída revela que você não sabia. -
Crie um proxy de medição para
MeioDePagamentoque imprima a duração de cada chamada comSystem.nanoTime, empilhado por cima do proxy de auditoria. Descreva a ordem em que os dois interceptam. -
Prove os dois preços da reflexão: renomeie um método de teste e mostre que nada quebra até a execução; use
setAccessiblepara ler um campoprivatedeProdutoe escreva em um parágrafo por que isso não invalida o encapsulamento como prática.
Ficha do capítulo
| Peça | O que faz |
|---|---|
@interface | declara uma anotação própria |
@Retention(RetentionPolicy.RUNTIME) | anotação visível em execução; exigida por executores |
Tipo.class / getClass() | o objeto Class que representa o tipo |
getDeclaredMethods / isAnnotationPresent | lista métodos; pergunta pela marca |
getDeclaredConstructor().newInstance() | cria instância pelo construtor |
Method.invoke(instancia, args) | chama o método representado |
Proxy.newProxyInstance(loader, interfaces, handler) | fabrica o proxy dinâmico |
| Termo | Definição |
|---|---|
| anotação própria | anotação declarada com @interface |
| retenção | até onde a anotação sobrevive: fonte, bytecode ou execução |
| reflexão | examinar e manipular tipos e membros em execução |
| proxy dinâmico | objeto fabricado em execução que implementa interfaces e delega tudo |
InvocationHandler | o ponto único que recebe cada chamada do proxy |
Injeção de dependência na unha
O Caixa do mercadinho precisa do estoque para vender, e a versão ingênua
resolve isso por conta própria:
public class Caixa {
private final RepositorioEmArquivo repositorio = new RepositorioEmArquivo(Path.of("estoque.txt"));
public Resultado vender(String codigo, int quantidade) {
Produto produto = repositorio.buscar(codigo);
// baixa o estoque, grava, devolve o resultado
}
}
Compila, funciona, e cobra em dois lugares que este livro já preparou.
O primeiro é o teste: o CaixaTest não tem como testar essa
classe sem tocar o disco de verdade, porque o new do repositório está
cravado dentro dela, fora de alcance. O segundo é a troca: no dia em que o
estoque for para outro lugar, o apêndice de banco de dados é esse dia, cada
classe que cravou new RepositorioEmArquivo precisa ser editada. O Caixa
sabe demais: além do próprio trabalho, vender, ele decidiu de onde vêm os
dados e como o acesso é construído. Este capítulo tira dele essa segunda
responsabilidade.
Injeção pelo construtor
O desenho tem três passos, todos já ensinados. Primeiro, a dependência vira contrato, uma interface:
public interface RepositorioDeProdutos {
Produto buscar(String codigo);
void salvar(Produto produto);
}
Segundo, as implementações cumprem o contrato: a
RepositorioEmArquivo, que é a RepositorioDeEstoqueEmArquivo da prática
do capítulo 21 rebatizada com o nome no tamanho do contrato, e uma irmã de
laboratório, a RepositorioEmMemoria, um HashMap por dentro, sem disco
nenhum.
Terceiro, o passo que dá nome ao capítulo: o Caixa para de criar e passa a
receber.
public class Caixa {
private final RepositorioDeProdutos repositorio;
public Caixa(RepositorioDeProdutos repositorio) {
this.repositorio = repositorio;
}
// vender continua igual
}
Isso é injeção de dependência: as dependências de um objeto entram prontas por fora, pelo construtor, em vez de serem criadas por dentro. Nenhum mecanismo novo participa, é um construtor comum recebendo uma interface, e o efeito no teste é imediato:
@Test
void vendaBaixaOEstoque() {
RepositorioDeProdutos repositorio = new RepositorioEmMemoria();
repositorio.salvar(new Produto("7891000100103", "Café 500g", new BigDecimal("19.90"), 25));
Caixa caixa = new Caixa(repositorio);
caixa.vender("7891000100103", 2);
assertEquals(23, repositorio.buscar("7891000100103").estoque());
}
O teste roda em memória, em milissegundos, sem arquivo e sem limpeza,
porque a fixture escolhe a implementação. A RepositorioEmMemoria é um
dublê (test double): uma implementação que cumpre o contrato só para
servir ao teste; o sistema em produção recebe a de
arquivo, e o Caixa é o mesmo bytecode nos dois mundos, sem saber em qual
está. Testabilidade e trocabilidade são a mesma propriedade olhada de dois
lados, e as duas nascem do mesmo gesto: quem usa não constrói.
Meses depois, alguém acrescenta ao Caixa um relatório de fim de dia, e na
pressa resolve a dependência ali mesmo:
public void fecharDia() {
RepositorioEmArquivo historico = new RepositorioEmArquivo(Path.of("estoque.txt"));
// grava o resumo do dia por cima do estoque
}
O CaixaTest continua inteiro verde. O que ele deixou de proteger?
Tudo que passa por fecharDia: o new escondido ignora o repositório
injetado, e o método fala com o arquivo de verdade mesmo quando o teste
acredita estar em memória. Na melhor hipótese o teste do fechamento falha em
máquina alheia por falta do arquivo; na pior, a suíte roda na máquina do
mercadinho e escreve por cima do estoque real, um teste verde com efeito
colateral em produção. Dependência escondida é exatamente isso, uma
dependência que não aparece no construtor, e a disciplina que a armadilha
compra é curta: se a classe precisa, o construtor declara; new de
dependência dentro de método é sinal de revisão reprovada.
Inversão de controle e o grafo de objetos
O gesto tem um nome maior. Inversão de controle é o padrão em que a classe deixa de controlar a criação e a escolha das próprias dependências, e esse controle sobe para quem a monta. Cada classe fica sabendo menos, o acoplamento cai, e sobra a pergunta prática: se ninguém cria as próprias dependências, quem cria todas?
A resposta é o ponto de montagem. Todo sistema com injeção tem um lugar,
tipicamente o main, onde os objetos são criados e conectados de uma vez:
public static void main(String[] args) throws IOException {
Path arquivo = args.length > 0 ? Path.of(args[0]) : Path.of("estoque.txt");
RepositorioDeProdutos repositorio = new RepositorioEmArquivo(arquivo);
Caixa caixa = new Caixa(repositorio);
CadernetaDeFiado caderneta = new CadernetaDeFiado(repositorio, new BigDecimal("200.00"));
TelaDoTerminal tela = new TelaDoTerminal(caixa, caderneta);
tela.rodar();
}
O que esse trecho constrói chama-se grafo de objetos: o conjunto dos
objetos do sistema e das referências entre eles, montado uma vez na
partida. O repositório entra em dois lugares e existe uma vez; a tela
conhece o caixa, o caixa conhece o repositório, e ninguém conhece mais do
que o próprio construtor pede. O main vira o único lugar do sistema onde
as decisões de composição moram, e trocar o arquivo por memória, ou por
qualquer implementação futura, é editar essas linhas e mais nenhuma.
Um container na unha
Num sistema grande, o main de montagem cresce, e a ordem das criações
vira quebra-cabeça. O capítulo 23 deu as peças para automatizar: um
container de injeção é o objeto que sabe construir o grafo sozinho, criando
cada tipo pedido e resolvendo os parâmetros de construtor recursivamente.
Um funcional cabe numa tela:
public class Container {
private final Map<Class<?>, Class<?>> vinculos = new HashMap<>();
private final Map<Class<?>, Object> prontos = new HashMap<>();
public void vincular(Class<?> contrato, Class<?> implementacao) {
vinculos.put(contrato, implementacao);
}
public void registrar(Class<?> tipo, Object valor) {
prontos.put(tipo, valor);
}
public <T> T resolver(Class<T> tipo) {
if (prontos.containsKey(tipo)) {
@SuppressWarnings("unchecked")
T pronto = (T) prontos.get(tipo);
return pronto;
}
try {
Class<?> alvo = vinculos.getOrDefault(tipo, tipo);
Constructor<?>[] construtores = alvo.getDeclaredConstructors();
if (construtores.length == 0) {
throw new IllegalStateException("Não sei construir " + tipo.getName());
}
Constructor<?> construtor = construtores[0];
Object[] argumentos = new Object[construtor.getParameterCount()];
Class<?>[] tiposDosParametros = construtor.getParameterTypes();
for (int i = 0; i < argumentos.length; i++) {
argumentos[i] = resolver(tiposDosParametros[i]);
}
Object instancia = construtor.newInstance(argumentos);
prontos.put(tipo, instancia);
return tipo.cast(instancia);
} catch (ReflectiveOperationException erro) {
throw new IllegalStateException("Não sei construir " + tipo.getName(), erro);
}
}
}
A leitura: vincular registra qual implementação atende cada contrato;
registrar guarda um valor já pronto, porque valor de configuração, um
caminho, um limite, não se constrói por construtor; resolver olha o
construtor da implementação, resolve cada parâmetro chamando a si mesmo,
recursivamente sobre o grafo, cria com o newInstance e guarda. A
conferência do array vazio pega o esquecimento clássico, o contrato sem
vincular, cujo alvo é uma interface sem construtor nenhum; o catch de
ReflectiveOperationException, a superclasse das exceções da reflexão,
embrulha o resto. O main encolhe para as decisões e um pedido:
Container container = new Container();
container.vincular(RepositorioDeProdutos.class, RepositorioEmArquivo.class);
container.registrar(Path.class, Path.of("estoque.txt"));
container.registrar(BigDecimal.class, new BigDecimal("200.00"));
TelaDoTerminal tela = container.resolver(TelaDoTerminal.class);
tela.rodar();
O caixa e a caderneta dependem os dois de RepositorioDeProdutos, e cada
um ganhou um acessor repositorio() de laboratório, só para a comparação:
Caixa caixa = container.resolver(Caixa.class);
CadernetaDeFiado caderneta = container.resolver(CadernetaDeFiado.class);
IO.println(caixa.repositorio() == caderneta.repositorio());
true ou false, e por quê?
true
O mapa prontos guarda cada instância criada e a reaproveita: dois pedidos
do mesmo tipo recebem o mesmo objeto, a mesma referência. Esse
regime tem nome, singleton: uma única instância de um tipo, compartilhada
por todo o sistema, e para um repositório é o regime certo, porque dois
repositórios do mesmo arquivo seriam dois caches brigando. O nome também
batiza um anti-padrão famoso, o singleton por campo estático global, que
recria o problema da armadilha, dependência escondida e intestável, com o
agravante do estado compartilhado; a diferença entre os dois
usos é quem controla, o container, visível e trocável, ou a própria classe,
escondida.
O que os frameworks acrescentam. O container de uma tela resolve
construtores; os de mercado somam o escaneio por anotações
para dispensar o vincular manual, escopos além do singleton, configuração
externa e diagnósticos de grafo. O mecanismo central, resolver construtores
recursivamente e cachear, é o desta página, e quem o escreveu uma vez lê
qualquer stack trace de container sem susto.
Prática
-
Extraia a interface
RepositorioDeProdutosdo mercadinho, implemente a versão em memória, e convertaCaixaeCadernetaDeFiadopara injeção pelo construtor. Meça o tempo da suíte de testes antes e depois de trocar o repositório dos testes para a memória. -
Reproduza a armadilha do
newescondido: faça o teste do fechamento passar enquanto grava num arquivo real, prove o efeito colateral, e conserte injetando. Escreva a regra de revisão em uma frase. -
Implemente o
Containercompleto e monte o mercadinho com ele. Depois vinculeRepositorioDeProdutosà memória e rode a suíte inteira pelo container, sem editar nenhuma classe de domínio. -
Acrescente ao container a detecção de ciclo:
Aque depende deBque depende deAhoje estoura a pilha de chamadas. Transforme o estouro numaIllegalStateExceptioncom a cadeia de tipos na mensagem. -
Escreva em um parágrafo, com o vocabulário deste capítulo, por que o relógio injetável do capítulo 20 é o mesmo padrão, e injete um
Clockfixo naCadernetaDeFiadopara testar o vencimento do fiado sem esperar trinta dias.
Ficha do capítulo
| Termo | Definição |
|---|---|
| injeção de dependência | dependências entram prontas pelo construtor, criadas por fora |
| inversão de controle | a criação e a escolha das dependências sobem para quem monta |
| grafo de objetos | os objetos do sistema e as referências entre eles, montados na partida |
| container | o objeto que constrói o grafo: resolve construtores recursivamente e cacheia |
| singleton | uma instância única compartilhada; regime do container, anti-padrão quando estático e escondido |
| Regra prática | |
|---|---|
| dependência | se a classe precisa, o construtor declara; new interno é dependência escondida |
| ponto de montagem | um só: o main ou o container; decisões de composição moram juntas |
| teste | dublê em memória via injeção; disco e relógio de verdade só onde são o assunto |
Projeto integrador: arquitetura e MVC no terminal
Vinte e quatro capítulos produziram todas as peças do mercadinho: produto e código de barras, caixa e pagamentos, estoque em arquivo, testes, injeção. Falta a decisão que transforma peças em sistema, e ela não é uma classe nova: é responder, para cada classe existente, onde ela mora e quem pode conhecer quem. A resposta é a estrutura final do projeto:
src/main/java/br/com/mercadinho/
├── dominio/
│ ├── Produto.java CodigoDeBarras.java
│ ├── ItemDeVenda.java Categoria.java
│ ├── Caixa.java CadernetaDeFiado.java
│ ├── MeioDePagamento.java Resultado.java
│ └── RepositorioDeProdutos.java
├── infraestrutura/
│ └── RepositorioEmArquivo.java
├── apresentacao/
│ ├── TelaDoTerminal.java
│ └── Impressora.java
└── Principal.java
As três camadas
A estrutura de pastas é a fotografia de três camadas, e cada uma tem
definição de uma frase. A camada de domínio contém as regras do negócio, os
tipos e as operações que existiriam em qualquer versão do mercadinho, sem
saber que terminal, arquivo ou banco existem. A camada de infraestrutura
contém as bordas técnicas, as classes que tocam o mundo, implementando
contratos que o domínio declara: o RepositorioEmArquivo
cumprindo o RepositorioDeProdutos do capítulo 24. A camada de apresentação
contém a conversa com quem usa: ler comandos, mostrar resultados, e nada
além disso.
O que faz das três uma arquitetura é a regra de dependência, uma só e sem exceção: as setas apontam para o domínio, e o domínio não aponta para ninguém.
flowchart LR
A["apresentação"] --> D["domínio"]
I["infraestrutura"] --> D
A apresentação chama o domínio; a infraestrutura implementa interfaces do
domínio; e o domínio compila sem que as outras duas existam, porque nenhum
import dele as menciona. Todo o investimento do livro converge nessa
regra: as interfaces são o que permite a seta da
infraestrutura apontar para dentro, a injeção é o que poupa o
domínio de criar as bordas, e o pacote é o muro que torna a
regra visível numa listagem de pastas.
A regra de dependência é verificável com o compilador, sem ferramenta nenhuma:
$ javac -d saida src/main/java/br/com/mercadinho/dominio/*.java
$ javac -d saida src/main/java/br/com/mercadinho/apresentacao/*.java
Um dos dois comandos funciona sozinho e o outro não. Qual, e por quê?
O primeiro compila: o domínio inteiro se resolve sem as outras camadas,
porque não importa nada delas. O segundo falha já na primeira mensagem,
error: package br.com.mercadinho.dominio does not exist, seguida da
fileira de cannot find symbol, porque a apresentação importa o domínio e
ele não está no classpath do comando. A assimetria é a arquitetura em forma executável, e
vale como teste de saúde a qualquer momento: no dia em que o domínio parar
de compilar sozinho, alguma seta inverteu, e a inversão tem nome na
armadilha adiante.
MVC
Dentro dessa organização, a camada de apresentação segue um padrão com nome
próprio. MVC, de Model-View-Controller, modelo, visão e controlador, é a
divisão da interação em três papéis: o modelo guarda o estado e as regras; a
visão apresenta o estado a quem usa; o controlador recebe o que o usuário
fez e traduz em chamadas ao modelo. No mercadinho de terminal, o modelo é a
camada de domínio inteira; a visão é a Impressora, que sabe transformar
resultados em texto; e o controlador é a TelaDoTerminal, o laço que lê
comandos e aciona o domínio:
public class TelaDoTerminal {
private final Caixa caixa;
private final Impressora impressora;
public TelaDoTerminal(Caixa caixa, Impressora impressora) {
this.caixa = caixa;
this.impressora = impressora;
}
public void rodar() {
boolean aberto = true;
while (aberto) {
String comando = IO.readln("mercadinho> ");
switch (comando) {
case "vender" -> vender();
case "estoque" -> impressora.listar(caixa.estoqueAtual());
case "sair" -> aberto = false;
default -> IO.println("Comandos: vender, estoque, sair");
}
}
}
private void vender() {
String codigo = IO.readln("Código de barras: ");
int quantidade = Integer.parseInt(IO.readln("Quantidade: "));
Resultado resultado = caixa.vender(codigo, quantidade);
impressora.mostrar(resultado);
}
}
Cada linha vem de um capítulo, com uma liberdade nova de passagem: o
switch também vale como instrução, executando um braço sem produzir
valor, e aceita String no seletor. Nenhuma linha decide regra de negócio:
o controlador pergunta, converte e repassa; quem sabe se a venda pode
acontecer é o Caixa, e quem sabe escrever “Pago: R$ 39.80” é a
Impressora, com o switch de padrões sobre o Resultado.
Uma honestidade de terminal: nesta interface, visão e controlador são
vizinhos de porta, e a fronteira entre eles é mais fina do que o padrão
sugere; o MVC aparece em forma plena quando a interface tem eventos e
estado próprios, e os apêndices de interface gráfica mostram exatamente
isso. O que
não muda de interface para interface é o lado do modelo: regra de negócio
nunca mora na tela.
O mercadinho decide dar 5% de desconto para compras acima de R$ 100,00, e a mudança é feita onde parecia mais fácil:
private void vender() {
String codigo = IO.readln("Código de barras: ");
int quantidade = Integer.parseInt(IO.readln("Quantidade: "));
Resultado resultado = caixa.vender(codigo, quantidade);
if (resultado instanceof Aprovado(BigDecimal valor)
&& valor.compareTo(new BigDecimal("100.00")) > 0) {
IO.println("Total com desconto: R$ " + valor.multiply(new BigDecimal("0.95")));
} else {
impressora.mostrar(resultado);
}
}
A tela mostra o desconto, o cliente paga o valor da tela, e o sistema segue sem nenhum erro. O que o fechamento do dia vai dizer?
Que faltou dinheiro na gaveta. O desconto existe só na impressão: o Caixa
registrou a venda pelo valor cheio, o relatório do fechamento soma o valor
cheio, e a caderneta, os testes e o arquivo nunca souberam do desconto,
porque a regra nasceu na camada errada; o padrão de registro, de passagem,
também casa dentro do instanceof, fora do case. Regra de negócio na
apresentação é uma regra que as outras vias do sistema não enxergam, e o
sintoma é sempre esse: divergência entre o que a tela disse e o que o
sistema registrou. A correção é de endereço, não de código: a política de
desconto entra no Caixa, testada no CaixaTest, e a tela volta a só
mostrar o Resultado. O que pega essa família em revisão é o próprio
sintoma, tela e relatório dizendo valores diferentes, e o controlador
ganhando lógica e import onde só devia haver tradução; o teste de
compilação da previsão fica reservado para o defeito irmão, a seta
invertida, quando o domínio passa a importar as bordas.
Testes por camada
A arquitetura paga o segundo dividendo na suíte. O domínio, onde mora tudo que pode dar errado de verdade, testa-se com teste unitário puro: dublês em memória via injeção, milissegundos por teste, dezenas de casos, todo o arsenal de testes do livro. A infraestrutura testa-se pouco e de outro jeito: alguns testes que escrevem e leem um arquivo temporário de verdade, porque o assunto dela é exatamente o disco. E a apresentação quase não se testa, de propósito: um controlador que só pergunta, converte e repassa não tem regra para errar, e mantê-lo fino é o que mantém essa conta verdadeira. A distribuição é o inverso da pirâmide de dificuldade: quanto mais perto do mundo externo, menos testes e mais lentos; quanto mais perto do domínio, mais testes e mais rápidos.
Montagem, empacotamento e o sistema de pé
Principal.java é o ponto de montagem do capítulo 24, e o jar executável é
o do capítulo 21, com um ajuste: agora que as classes têm pacote, o
mainClass do pom.xml passa a ser o nome qualificado,
br.com.mercadinho.Principal. A sessão final é a razão do livro:
$ mvn package
[INFO] Tests run: 34, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
$ java -jar target/mercadinho-1.0.jar dados/estoque.txt
mercadinho> estoque
7891000100103 Café 500g R$ 19.90 25 un
7891000200100 Queijo minas R$ 39.80 8 un
mercadinho> vender
Código de barras: 7891000100103
Quantidade: 2
Pago: R$ 39.80
mercadinho> sair
$
Um arquivo, uma máquina com JDK, e o mercadinho atende. O sistema que essa sessão mostra é pequeno; a estrutura dele é a mesma de sistemas grandes, e essa é a aposta do capítulo: quem sabe onde cada coisa mora num sistema de quinze classes sabe procurar num de mil e quinhentas. Os três apêndices esticam exatamente este projeto, cada um por uma borda: o primeiro troca a infraestrutura de arquivo por um banco de dados, e a regra de dependência garante que o domínio não saberá; os outros dois trocam a apresentação de terminal por uma interface gráfica, onde o MVC mostra a forma plena. O domínio, coração do sistema e do livro, atravessa os três sem uma linha editada.
Nomes de mercado. A organização deste capítulo aparece na literatura com vários nomes, arquitetura em camadas, portas e adaptadores, arquitetura hexagonal, cada um com refinamentos próprios sobre o mesmo núcleo: domínio no centro, dependências apontando para dentro, bordas trocáveis. Quem dominou a versão de três pastas lê qualquer um dos diagramas famosos como variação, não como novidade.
Prática
O projeto integrador é a prática, e os itens são incrementos completos, cada um atravessando as camadas pelo caminho certo.
-
Monte o projeto na estrutura deste capítulo, migre todas as classes dos capítulos anteriores para as camadas, e faça o teste de saúde da previsão passar: o domínio compila sozinho.
-
Acrescente o comando
repor, de ponta a ponta: leitura na tela, regra e validação no domínio, persistência na infraestrutura, e os testes no lugar certo de cada parte. -
Implemente a política de desconto da armadilha no lugar certo, com o
Resultadoganhando a informação do desconto aplicado, o switch daImpressoraexibindo, e o teste provando que relatório e tela dizem o mesmo valor. -
Acrescente o comando
relatorio: total do dia, vendas por categoria e os três mais vendidos, tudo com pipelines de stream sobre os dados do domínio. -
Acrescente o fiado como meio de pagamento no fluxo de venda, com a caderneta, o limite e a exceção de limite estourado atravessando as camadas até virarem uma mensagem digna na tela.
-
Rode o sistema inteiro pelo jar em outra pasta e em outra máquina se puder, com o estoque por argumento, e escreva um parágrafo sobre o que precisou de ajuste e o que rodou intocado.
Ficha do capítulo
| Termo | Definição |
|---|---|
| camada de domínio | as regras do negócio; não conhece terminal, disco nem banco |
| camada de infraestrutura | as bordas técnicas; implementa contratos do domínio |
| camada de apresentação | a conversa com quem usa; pergunta, converte, repassa, mostra |
| MVC | modelo guarda estado e regras; visão mostra; controlador traduz entrada em chamadas |
| Regra prática | |
|---|---|
| dependência | setas para o domínio; o domínio não aponta para ninguém |
| teste de saúde | o domínio compila sozinho; quando parar, uma seta inverteu |
| regra de negócio | nunca na tela; divergência tela-relatório é o sintoma |
| testes | muitos e rápidos no domínio; poucos e reais na infraestrutura; apresentação fina |
JDBC: o mercadinho num banco de dados
Este apêndice ainda não foi escrito, e entra numa próxima versão do livro. O corpo do livro, os capítulos 1 a 25, está completo.
Este apêndice troca a persistência em arquivo do projeto integrador por um
banco de dados relacional, com JDBC: conexão, PreparedStatement,
ResultSet, transações e o mapeamento entre tabelas e o domínio. A regra de
dependência do capítulo 25 é o que está em teste aqui, e o domínio
atravessa a troca sem uma linha editada.
Dois pré-requisitos ficam declarados desde já, porque o apêndice não os ensina: SQL e um banco de dados em funcionamento, configurado com docker-compose ou sem ele.
JavaFX: janela, cena e o thread de UI
Este apêndice ainda não foi escrito, e entra numa próxima versão do livro. O corpo do livro, os capítulos 1 a 25, está completo.
Este apêndice abre a primeira janela do mercadinho: a estrutura de
Application, Stage e Scene, os controles básicos, o arranjo dos
elementos na tela e a regra que governa toda interface gráfica, a de que
existe um thread próprio para a interface e o que pode ser feito nele.
JavaFX: eventos, properties e binding
Este apêndice ainda não foi escrito, e entra numa próxima versão do livro. O corpo do livro, os capítulos 1 a 25, está completo.
Este apêndice trata do que faz uma janela responder: eventos de teclado e de mouse, as properties observáveis do JavaFX e o binding, a ligação que mantém a tela em dia com o valor que ela exibe, sem código de atualização espalhado.
JavaFX: ligando a tela ao domínio
Este apêndice ainda não foi escrito, e entra numa próxima versão do livro. O corpo do livro, os capítulos 1 a 25, está completo.
Este apêndice fecha a interface gráfica ligando a janela ao domínio do projeto integrador, com o MVC do capítulo 25 na forma plena que o terminal não permitia: o modelo intocado, a visão declarada, e o controlador traduzindo interação em chamadas de negócio.
Java legado: lendo o código que existe
Este apêndice ainda não foi escrito, e entra numa próxima versão do livro. O corpo do livro, os capítulos 1 a 25, está completo.
Este apêndice ensina a ler o que o livro deliberadamente não ensinou a
escrever, e que existe aos milhões em código de produção: System.out e
Scanner, Date e Calendar, StringBuffer, classes anônimas, tipos
genéricos crus e o finally de fechamento. Nada disso é para escrever;
tudo isso é para reconhecer.