O Java é consistentemente uma das três linguagens mais populares do mundo. Sua adoção em áreas como desenvolvimento de software empresarial, aplicativos móveis Android e aplicações web em larga escala é incomparável. Seu sistema de tipos forte, ecossistema extenso e capacidade de “escrever uma vez, executar em qualquer lugar” o tornam particularmente atraente para a construção de sistemas robustos e escaláveis. Neste artigo, vamos explorar como as características de programação orientada a objetos do Java permitem aos desenvolvedores aproveitar essas capacidades de forma eficaz, permitindo-lhes construir aplicações escaláveis e de fácil manutenção por meio de organização e reutilização adequadas do código.
Uma Nota sobre Organização e Execução de Código Java
Antes de começarmos a escrever qualquer código, vamos realizar algumas configurações.
Assim como em sua sintaxe, o Java possui regras rígidas sobre a organização do código.
Primeiro, cada classe pública deve estar em seu próprio arquivo, nomeado exatamente como a classe, mas com uma extensão.java
. Portanto, se eu quiser escrever uma classe Laptop, o nome do arquivo deve ser Laptop.java
—com distinção entre maiúsculas e minúsculas. É possível ter classes não públicas no mesmo arquivo, mas é melhor separá-las. Eu sei que estamos nos adiantando—falando sobre a organização de classes mesmo antes de escrevê-las—mas ter uma ideia aproximada de onde colocar as coisas antecipadamente é uma boa ideia.
Todos os projetos Java devem ter um arquivo Main.java
com a classe Main. É aqui que você testa suas classes criando objetos a partir delas.
Para executar código Java, usaremos IntelliJ IDEA, uma IDE Java popular. Após instalar o IntelliJ:
- Crie um novo projeto Java (Arquivo > Novo > Projeto)
- Clique com o botão direito na pasta src para criar o arquivo
Main.java
e cole o seguinte conteúdo:
public class Main { public static void main(String[] args) { // Criar e testar objetos aqui } }
Quando estamos falando sobre classes, escrevemos código em arquivos diferentes doMain.java
. Mas se estivermos falando sobre a criação e teste de objetos, mudamos para Main.java
.
Para executar o programa, você pode clicar no botão verde de reprodução ao lado do método principal:
A saída será exibida na janela da ferramenta Run na parte inferior.
Se você é completamente novo no Java, por favor, confira nosso Curso de Introdução ao Java, que abrange os fundamentos dos tipos de dados e fluxo de controle do Java antes de continuar.
Caso contrário, vamos direto ao assunto.
Classes e Objetos em Java
Então, o que são classes, exatamente?
Classes são construções de programação em Java para representar conceitos do mundo real. Por exemplo, considere esta classe MenuItem (crie um arquivo para escrever esta classe em seu IDE):
public class MenuItem { public String name; public double price; }
A classe nos dá um modelo ou um template para representar vários itens de menu em um restaurante. Ao alterar os dois atributos da classe, name
, e price
, podemos criar inúmeros objetos de menu, como um hambúrguer ou uma salada.
Para criar uma classe em Java, você começa uma linha que descreve o nível de acesso da classe (private
, public
, ou protected
) seguido pelo nome da classe. Imediatamente após as chaves, você descreve os atributos da sua classe.
Mas como criamos objetos que pertencem a esta classe? O Java permite isso através de métodos construtores:
public class MenuItem { public String name; public double price; // Construtor public MenuItem(String name, double price) { this.name = name; this.price = price; } }
Um construtor é um método especial que é chamado quando criamos um novo objeto a partir de uma classe. Ele inicializa os atributos do objeto com os valores que fornecemos. No exemplo acima, o construtor recebe um parâmetro de nome e preço e os atribui aos campos do objeto usando a palavra-chave ‘this’ para se referir a uma instância futura do objeto.
A sintaxe para o construtor é diferente de outros métodos de classe porque não exige que você especifique um tipo de retorno. Além disso, o construtor deve ter o mesmo nome que a classe e deve ter o mesmo número de atributos que você declarou após a definição da classe. Acima, o construtor está criando dois atributos porque declaramos dois após a definição da classe: nome
e preço
.
Depois de escrever sua classe e seu construtor, você pode criar instâncias (objetos) dela em seu método principal:
public class Main { public static void main(String[] args) { // Criar objetos aqui MenuItem burger = new MenuItem("Burger", 3.5); MenuItem salad = new MenuItem("Salad", 2.5); System.out.println(burger.name + ", " + burger.price); } }
Saída:
Burger, 3.5
Acima, estamos criando dois MenuItem
objetos nas variáveis burger
e salad
. Como exigido em Java, o tipo da variável deve ser declarado, que é MenuItem
. Em seguida, para criar uma instância da nossa classe, escrevemos a nova palavra-chave seguida pela invocação do método construtor.
Além do construtor, você pode criar métodos regulares que dão comportamento à sua classe. Por exemplo, abaixo, adicionamos um método para calcular o preço total após o imposto:
public class MenuItem { public String name; public double price; // Construtor public MenuItem(String name, double price) { this.name = name; this.price = price; } // Método para calcular o preço após o imposto public double getPriceAfterTax() { double taxRate = 0.08; // Taxa de 8% return price + (price * taxRate); } }
Agora podemos calcular o preço total, incluindo o imposto:
public class Main { public static void main(String[] args) { MenuItem burger = new MenuItem("Burger", 3.5); System.out.println("Price after tax: $" + burger.getPriceAfterTax()); } }
Saída:
Price after tax: $3.78
Encapsulamento
O objetivo das classes é fornecer um modelo para a criação de objetos. Esses objetos serão então usados por outros scripts ou programas. Por exemplo, nossos objetos MenuItem
podem ser usados por uma interface de usuário que exibe seus nomes, preços e imagens em uma tela.
Por esse motivo, devemos projetar nossas classes de forma que suas instâncias só possam ser usadas conforme pretendido. No momento, nossa classe MenuItem
é muito básica e propensa a erros. Uma pessoa pode criar objetos com atributos ridículos, como uma torta de maçã com preço negativo ou um sanduíche de um milhão de dólares:
// Dentro de Main.java MenuItem applePie = new MenuItem("Apple Pie", -5.99); // Preço negativo! MenuItem sandwich = new MenuItem("Sandwich", 1000000); // Extremamente caro System.out.println("Apple pie price: $" + applePie.price); System.out.println("Sandwich price: $" + sandwich.price);
Então, a primeira coisa a fazer após escrever uma classe é proteger seus atributos limitando como eles são criados e acessados. Para começar, queremos permitir apenas valores positivos para preço e definir um valor máximo para evitar mostrar itens ridiculamente caros acidentalmente.
Java nos permite realizar isso usando métodos setter:
public class MenuItem { private String name; private double price; private static final double MAX_PRICE = 100.0; public MenuItem(String name, double price) { this.name = name; setPrice(price); } public void setPrice(double price) { if (price < 0) { throw new IllegalArgumentException("Price cannot be negative"); } if (price > MAX_PRICE) { throw new IllegalArgumentException("Price cannot exceed $" + MAX_PRICE); } this.price = price; } }
Vamos examinar o que há de novo no bloco de código acima:
1. Fizemos os atributos privados adicionando a palavra-chave private
. Isso significa que eles só podem ser acessados dentro da classe MenuItem. A encapsulação começa com este passo crucial.
2. Adicionamos uma nova constante MAX_PRICE
que é:
- privada (apenas acessível dentro da classe)
- estático (compartilhado entre todas as instâncias)
- final (não pode ser alterado após a inicialização)
- definido como $100,0 como um preço máximo razoável
3. Adicionamos um setPrice()
método que:
- recebe um parâmetro de preço
- Valida que o preço não é negativo
- Valida que o preço não excede MAX_PRICE
- Lança IllegalArgumentException com mensagens descritivas se a validação falhar
- Apenas define o preço se todas as validações passarem
4. Modificamos o construtor para usar setPrice()
em vez de atribuir diretamente o preço. Isso garante que a validação do preço ocorra durante a criação do objeto.
Acabamos de implementar um dos pilares centrais do bom design orientado a objetos –encapsulamento. Esse paradigma garante o ocultamento de dados e acesso controlado aos atributos do objeto, garantindo que os detalhes de implementação internos sejam protegidos de interferências externas e só possam ser modificados por meio de interfaces bem definidas.
Vamos reforçar esse ponto aplicando encapsulamento ao atributo nome. Imagine que temos uma cafeteria que serve apenas lattes, cappuccinos, espressos, americanos e mochas.
Portanto, os nomes dos itens do nosso menu só podem ser um dos itens desta lista. Aqui está como podemos aplicar isso no código:
// Restante da classe aqui ... private static final String[] VALID_NAMES = {"latte", "cappuccino", "espresso", "americano", "mocha"}; private String name; public void setName(String name) { String lowercaseName = name.toLowerCase(); for (String validName : VALID_NAMES) { if (validName.equals(lowercaseName)) { this.name = name; return; } } throw new IllegalArgumentException("Invalid drink name. Must be one of: " + String.join(", ", VALID_NAMES)); }
O código acima implementa a validação de nomes para os itens do menu em uma cafeteria. Vamos analisar:
1. Primeiro, ele define um array final estático privado VALID_NAMES
que contém os únicos nomes de bebida permitidos: latte, cappuccino, espresso, americano e mocha. Este array sendo:
- privado: acessível apenas dentro da classe
- estático: compartilhado entre todas as instâncias
- final: não pode ser modificado após a inicialização
2. Declara um campo String name privado para armazenar o nome da bebida
3. O método setName()
implementa a lógica de validação:
- Recebe um parâmetro de nome String
- Converte para minúsculas para tornar a comparação insensível a maiúsculas e minúsculas
- Percorre o array
VALID_NAMES
- Se for encontrada uma correspondência, define o nome e retorna
- Se nenhuma correspondência for encontrada, lança uma IllegalArgumentException com uma mensagem descritiva listando todas as opções válidas
Aqui está a classe completa até agora:
public class MenuItem { private String name; private double price; private static final String[] VALID_NAMES = {"latte", "cappuccino", "espresso", "americano", "mocha"}; private static final double MAX_PRICE = 100.0; public MenuItem(String name, double price) { setName(name); setPrice(price); } public void setName(String name) { String lowercaseName = name.toLowerCase(); for (String validName : VALID_NAMES) { if (validName.equals(lowercaseName)) { this.name = name; return; } } throw new IllegalArgumentException("Invalid drink name. Must be one of: " + String.join(", ", VALID_NAMES)); } public void setPrice(double price) { if (price < 0) { throw new IllegalArgumentException("Price cannot be negative"); } if (price > MAX_PRICE) { throw new IllegalArgumentException("Price cannot exceed $" + MAX_PRICE); } this.price = price; } }
Depois de protegermos a maneira como os atributos são criados, também queremos proteger como eles são acessados. Isso é feito usando métodos getter:
public class MenuItem { // Restante do código aqui ... public String getName() { return name; } public double getPrice() { return price; } }
Os métodos getter fornecem acesso controlado aos atributos privados de uma classe. Eles resolvem o problema de acesso direto aos atributos que pode levar a modificações indesejadas e quebrar a encapsulação.
Por exemplo, sem getters, podemos acessar atributos diretamente:
MenuItem item = new MenuItem("Latte", 4.99); String name = item.name; // Acesso direto ao atributo item.name = "INVALID"; // Pode ser modificado diretamente, evitando a validação
Com getters, aplicamos um acesso apropriado:
MenuItem item = new MenuItem("Latte", 4.99); String name = item.getName(); // Acesso controlado através do getter // item.name = "INVÁLIDO"; // Não permitido - deve usar setName() que valida
Esta encapsulação:
- Protege a integridade dos dados ao impedir modificações inválidas
- Permite alterar a implementação interna sem afetar o código que usa a classe
- Fornece um único ponto de acesso que pode incluir lógica adicional, se necessário
- Torna o código mais fácil de manter e menos propenso a erros
Herança
A nossa classe está começando a ficar boa, mas há muitos problemas com ela. Por exemplo, para um grande restaurante que serve muitos tipos de pratos e bebidas, a classe não é flexível o suficiente.
Se quisermos adicionar diferentes tipos de itens alimentares, vamos encontrar vários desafios. Alguns pratos podem ser preparados para viagem, enquanto outros precisam ser consumidos imediatamente. Os itens do menu podem ter preços e descontos variados. Os pratos podem precisar de rastreamento de temperatura ou armazenamento especial. As bebidas podem ser quentes ou frias, com ingredientes personalizáveis. Os itens podem precisar de informações sobre alérgenos e opções de porções. O sistema atual não lida com esses requisitos variados.
A herança fornece uma solução elegante para todos esses problemas. Isso nos permite criar versões especializadas de itens de menu definindo uma classe base MenuItem
com atributos comuns e, em seguida, criando classes filhas que herdam esses básicos enquanto adicionam recursos exclusivos.
Por exemplo, poderíamos ter uma classe Drink
para bebidas com opções de temperatura, uma classe Food
para itens que precisam ser consumidos imediatamente, e uma classe Dessert
para itens com necessidades especiais de armazenamento – todas herdando a funcionalidade principal do item de menu.
Estendendo classes
Vamos implementar essas ideias começando com Drink
:
public class Drink extends MenuItem { private boolean isCold; public Drink(String name, double price, boolean isCold) { this.name = name; this.price = price; this.isCold = isCold; } public boolean getIsCold() { return isCold; } public void setIsCold(boolean isCold) { this.isCold = isCold; } }
Para definir uma classe filha que herda de uma classe pai, usamos a palavra-chaveextends
após o nome da classe filha seguido pelo nome da classe pai. Após a definição da classe, definimos quaisquer novos atributos que esta classe filha tenha e implementamos seu construtor.
Mas note como temos que repetir a inicialização de name
e price
junto com isCold
. Isso não é ideal porque a classe pai pode ter centenas de atributos. Além disso, o código acima gerará um erro quando você compilá-lo porque essa não é a maneira correta de inicializar atributos da classe pai. A maneira correta seria usando a palavra-chave super
:
public class Drink extends MenuItem { private boolean isCold; public Drink(String name, double price, boolean isCold) { super(name, price); this.isCold = isCold; } public boolean getIsCold() { return isCold; } public void setIsCold(boolean isCold) { this.isCold = isCold; } }
A super
palavra-chave é usada para chamar o construtor da classe pai. Neste caso, super(name, price)
chama MenuItem
‘s construtor para inicializar esses atributos, evitando duplicação de código. Nós só precisamos inicializar o novo isCold
atributo específico da Drink
classe.
A palavra-chave é muito flexível porque você pode usá-la para se referir à classe pai em qualquer parte da classe filha. Por exemplo, para chamar um método pai, você usa super.nomeDoMetodo()
enquanto super.nomeDoAtributo
é para atributos.
Sobreposição de método
Agora, digamos que queremos adicionar um novo método às nossas classes para calcular o preço total após impostos. Como diferentes itens de menu podem ter diferentes taxas de imposto (por exemplo, alimentos preparados vs. bebidas embaladas), podemos usar a sobreposição de método para implementar cálculos de impostos específicos em cada classe filha, mantendo um nome de método comum na classe pai.
Aqui está como isso se parece:
public class MenuItem { // Resto da classe MenuItem public double calculateTotalPrice() { // Taxa de imposto padrão de 10% return price * 1.10; } } public class Food extends MenuItem { private boolean isVegetarian; public Food(String name, double price, boolean isVegetarian) { super(name, price); this.isVegetarian = isVegetarian; } @Override public double calculateTotalPrice() { // Alimentos têm 15% de imposto return super.getPrice() * 1.15; } } public class Drink extends MenuItem { private boolean isCold; public Drink(String name, double price, boolean isCold) { super(name, price); this.isCold = isCold; } @Override public double calculateTotalPrice() { // Bebidas têm 8% de imposto return super.getPrice() * 1.08; } }
Neste exemplo, a sobrescrita de métodos permite que cada subclasse forneça sua própria implementação de calculateTotalPrice()
:
A classe MenuItem
define um cálculo de imposto padrão de 10%.
Quando Comida
e Bebida
estendem as classes MenuItem
, elas substituem este método para implementar suas próprias taxas de imposto:
- Os itens de comida têm uma taxa de imposto mais alta de 15%
- Bebidas têm uma taxa de imposto mais baixa de 8%
O @Override
é usado para indicar explicitamente que esses métodos estão substituindo o método da classe pai. Isso ajuda a detectar erros se a assinatura do método não corresponder à da classe pai.
Cada subclasse ainda pode acessar o preço da classe pai usando super.getPrice()
, demonstrando como métodos substituídos podem utilizar a funcionalidade da classe pai enquanto adicionam seu comportamento.
Em resumo, a sobrescrita de método é uma parte integral da herança que permite que subclasses forneçam sua implementação de métodos definidos na classe pai, possibilitando comportamentos mais específicos mantendo a mesma assinatura do método.
Classes Abstratas
Nossa hierarquia de classes MenuItem
funciona, mas há um problema: qualquer pessoa deveria ser capaz de criar um objeto de MenuItem
genérico? Afinal, em nosso restaurante, todo item de menu é ou uma Comida ou uma Bebida – não existe apenas um “item de menu genérico”.
Podemos evitar isso tornandoMenuItem
uma classe abstrata. Uma classe abstrata fornece apenas um esquema base – ela pode ser usada apenas como classe pai para herança, não sendo instanciada diretamente.
Para tornarMenuItem
abstrato, adicionamos a palavra-chave abstract
após o modificador de acesso:
public abstract class MenuItem { private String name; private double price; public MenuItem(String name, double price) { setName(name); setPrice(price); } // Os getters/setters existentes permanecem os mesmos // Torne este método abstrato - toda subclasse DEVE implementá-lo public abstract double calculateTotalPrice(); }
As classes abstratas também podem ter métodos abstratos como calculateTotalPrice()
acima. Esses métodos abstratos funcionam como contratos que obrigam as subclasses a fornecer suas implementações. Em outras palavras, qualquer método abstrato em uma classe abstrata deve ser implementado pelas classes filhas.
Então, vamos reescrever Comida
e Bebida
com essas mudanças em mente:
public class Food extends MenuItem { private boolean isVegetarian; public Food(String name, double price, boolean isVegetarian) { super(name, price); this.isVegetarian = isVegetarian; } @Override public double calculateTotalPrice() { return getPrice() * 1.15; // 15% de imposto } } public class Drink extends MenuItem { private boolean hasCaffeine; public Drink(String name, double price, boolean hasCaffeine) { super(name, price); this.hasCaffeine = hasCaffeine; } @Override public double calculateTotalPrice() { return getPrice() * 1.10; // 10% de imposto } }
Através da implementação deste sistema de menu, vimos como a abstração e a herança trabalham juntas para criar um código flexível e de fácil manutenção que pode se adaptar facilmente a diferentes requisitos de negócios.
Conclusão
Hoje, tivemos um vislumbre do que o Java é capaz como uma linguagem de programação orientada a objetos. Abordamos o básico sobre classes, objetos e alguns pilares principais da POO: encapsulamento, herança e abstração através de um sistema de menu de restaurante.
Para tornar esse sistema pronto para produção, você ainda tem muitas coisas para aprender, como interfaces (parte da abstração), polimorfismo e padrões de design em POO. Para saber mais sobre esses conceitos, consulte nosso Introdução à POO em Java curso.
Se deseja testar seus conhecimentos em Java, tente responder algumas das perguntas em nosso artigo de Perguntas de Entrevista em Java.