POO em Java: Classes, Objetos, Encapsulamento, Herança e Abstração

Java é consistentemente uma das três linguagens mais populares do mundo. Sua adoção em áreas como desenvolvimento de software corporativo, aplicativos móveis Android e aplicações web em larga escala é incomparável. Seu sistema de tipos forte, ecossistema extenso e a capacidade de “escrever uma vez, executar em qualquer lugar” o tornam particularmente atraente para construir sistemas robustos e escaláveis. Neste artigo, vamos explorar como os recursos de programação orientada a objetos do Java permitem que os desenvolvedores aproveitem essas capacidades de forma eficaz, permitindo que construam aplicações manuteníveis e escaláveis por meio de uma organização e reutilização adequada 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 fazer algumas configurações.

Assim como em sua sintaxe, o Java tem regras rigorosas sobre organização de código.

Primeiro, cada classe pública deve estar em seu próprio arquivo, nomeado exatamente como a classe, mas com uma .java extensão. Então, se eu quiser escrever uma classe Laptop, o nome do arquivo deve ser Laptop.javasensível a maiúsculas e minúsculas. Você pode ter classes não públicas no mesmo arquivo, mas é melhor separá-las. Eu sei que estamos nos adiantando—falando sobre a organização das classes antes mesmo de escrevê-las—mas ter uma ideia geral 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, vamos usar IntelliJ IDEA, uma IDE Java popular. Após instalar o IntelliJ:

  1. Crie um novo projeto Java (Arquivo > Novo > Projeto)
  2. 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) { // Crie e teste objetos aqui } }

Sempre que estamos falando sobre classes, escrevemos código em outros arquivos que não o Main.java arquivo. Mas se estamos falando sobre criar e testar objetos, mudamos para Main.java.

Para executar o programa, você pode clicar no botão verde de executar ao lado do método principal:

A saída será exibida na janela da ferramenta Run na parte inferior.

Se você é completamente novo em Java, por favor, confira nosso Curso de Introdução ao Java, que cobre os fundamentos dos tipos de dados Java e do fluxo de controle antes de continuar.

Caso contrário, vamos direto ao ponto.

Classes e Objetos Java

Então, o que são classes, exatamente?

Classes são estruturas de programação em Java para representar conceitos do mundo real. Por exemplo, considere esta classe MenuItem (crie um arquivo para escrever esta classe no seu IDE):

public class MenuItem { public String name; public double price; }

A classe nos dá um modelo ou um padrão para representar vários itens de menu em um restaurante. Ao alterar os dois atributos da classe, nome, e preço, podemos criar inúmeros objetos de menu como um hambúrguer ou uma salada.

Então, para criar uma classe em Java, você inicia 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 de sua classe.

Mas como criamos objetos que pertencem a essa classe? O Java possibilita 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 futura instância do objeto.

A sintaxe do construtor é diferente das outras métodos da classe porque não exige que você especifique um tipo de retorno. Além disso, o construtor deve ter o mesmo nome da 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 no seu método principal:

public class Main { public static void main(String[] args) { // Crie 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 palavra-chave new 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 imposto 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 utilizados por outros scripts ou programas. Por exemplo, nossos objetos MenuItem podem ser utilizados por uma interface do usuário que exibe seus nome, preço e imagem em uma tela.

Por esse motivo, devemos projetar nossas classes de forma que suas instâncias só possam ser usadas da maneira que pretendemos. Atualmente, 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); // Preço absurdamente alto System.out.println("Apple pie price: $" + applePie.price); System.out.println("Sandwich price: $" + sandwich.price);

Portanto, a primeira ordem de negócios 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 a exibição acidental de itens ridiculamente caros.

O 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. Tornamos os atributos privados ao adicionar a private palavra-chave. Isso significa que só podem ser acessados dentro da classe MenuItem. A encapsulação começa com esse passo crucial.

2. Adicionamos uma nova constante MAX_PRICE que é:

  • privada (acessível apenas 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
  • Define o preço apenas se todas as validações forem aprovadas

4. Modificamos o construtor para usar setPrice() em vez de atribuir o preço diretamente. Isso garante que a validação do preço ocorra durante a criação do objeto.

Acabamos de implementar um dos pilares fundamentais do bom design orientado a objetos — encapsulamento. Esse paradigma impõe o ocultamento de dados e o 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 o ponto aplicando o encapsulamento ao atributo nome. Imagine que temos uma cafeteria que serve apenas lattes, cappuccinos, espressos, americanos e mochas.

Então, os nomes dos itens do nosso menu podem ser apenas um dos itens desta lista. Aqui está como podemos impor isso no código:

// Resto 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 itens do menu em uma cafeteria. Vamos detalhar:

1. Primeiro, ele define um array privado estático final VALID_NAMES que contém os únicos nomes de bebidas permitidos: latte, cappuccino, espresso, americano e mocha. Este array é:

  • 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 case-insensitive
  • Percorre o array VALID_NAMES
  • Se for encontrado 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 proteger a forma 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, poderíamos acessar atributos diretamente:

MenuItem item = new MenuItem("Latte", 4.99); String name = item.name; // Acesso direto ao atributo item.name = "INVALID"; // Pode modificar diretamente, ignorando a validação

Com getters, aplicamos o acesso adequado:

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:

  1. Protege a integridade dos dados ao evitar modificações inválidas
  2. Permite alterar a implementação interna sem afetar o código que usa a classe
  3. Fornece um único ponto de acesso que pode incluir lógica adicional, se necessário
  4. Torna o código mais sustentável e menos propenso a erros

Herança

Nossa classe está começando a ficar boa, mas há muitos problemas com ela. Por exemplo, para um restaurante grande que serve muitos tipos de pratos e bebidas, a classe não é flexível o suficiente.

Se quisermos adicionar diferentes tipos de alimentos, enfrentaremos 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. Pratos podem precisar rastreamento de temperatura ou armazenamento especial. 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ção. O sistema atual não lida com esses requisitos variados.

A herança oferece uma solução elegante para todos esses problemas. Permite-nos 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-chave extends após o nome da classe filha seguido pela classe pai. Após a definição da classe, definimos quaisquer novos atributos que esta classe filha possui e implementamos seu construtor.

Mas observe como temos que repetir a inicialização de nome e preço juntamente com éFrio. Isso não é ideal porque a classe pai pode ter centenas de atributos. Além disso, o código acima lançará um erro quando compilado, pois não é a forma correta de inicializar atributos da classe pai. A forma 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; } }

O super palavra-chave é usada para chamar o construtor da classe pai. Neste caso, super(nome, preço) chama o construtor da classe MenuItem para inicializar esses atributos, evitando a duplicação de código. Só precisamos inicializar o novo atributo isCold específico da classe Drink.

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 da classe pai, você usa super.methodName() enquanto super.attributeName é para atributos.

Sobrecarga de métodos

Agora, digamos que queremos adicionar um novo método às nossas classes para calcular o preço total após o imposto. Como diferentes itens do menu podem ter diferentes taxas de imposto (por exemplo, alimentos preparados vs. bebidas embaladas), podemos usar a sobrecarga de métodos para implementar cálculos específicos de impostos em cada classe filha, mantendo um nome de método comum na classe pai.

Aqui está como isso se parece:

public class MenuItem { // Restante 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() { // Comida tem 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 sobreposição de métodos permite que cada subclasse forneça sua própria implementação de calculateTotalPrice():

A classe MenuItem define um cálculo padrão de imposto de 10%.

Quando Alimento e Bebida classes estendem ItemDeMenu, elas sobrescrevem este método para implementar suas próprias taxas de imposto:

  • Os itens alimentares têm uma taxa de imposto mais alta de 15%
  • As 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 substituição 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, permitindo comportamentos mais específicos mantendo a mesma assinatura do método.

Classes Abstratas

Nossa MenuItem hierarquia de classes funciona, mas há um problema: qualquer pessoa deveria poder criar um objeto MenuItem genérico? Afinal, em nosso restaurante, todo item de menu é ou Comida ou Bebida – não existe apenas um “item de menu genérico”.

Podemos prevenir isso tornando MenuItem uma classe abstrata. Uma classe abstrata fornece apenas um esboço base – ela só pode ser usada como uma classe pai para herança, não pode ser instanciada diretamente.

Para tornarMenuItemabstrato, adicionamos a palavra-chaveabstractapó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 iguais // Torna este método abstrato - toda subclasse DEVE implementá-lo public abstract double calculateTotalPrice(); }

Classes abstratas também podem ter métodos abstratos como calculateTotalPrice() acima. Esses métodos abstratos servem 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 desse sistema de menu, vimos como a abstração e a herança trabalham juntas para criar um código flexível e fácil de manter, que pode se adaptar facilmente a diferentes requisitos de negócios.

Conclusão

Hoje, tivemos uma visão do que o Java é capaz como uma linguagem de programação orientada a objetos. Abordamos o básico de classes, objetos e alguns pilares fundamentais da POO: encapsulamento, herança e abstração através de um sistema de menu de restaurante.

Para tornar este sistema pronto para produção, você ainda tem muitas coisas a 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 de Java, tente responder algumas das perguntas em nosso artigo de Perguntas de Entrevista em Java.

Source:
https://www.datacamp.com/tutorial/oop-in-java