Este tutorial apresenta uma serie de conceitos e práticas sobre os padrões de projetos, para isso será contextualizado alguns conceitos básicos de Programação Orientada Objeto (POO), e em seguida serão abordados os principais padrões de criação, estrutural e comportamentais. A seção de Padrões de Projetos apresenta exemplos e explicações adaptadas de várias fontes, principalmente do sites do Marcos Brizeno e do Source Making.
-
Programação Orientada Objeto: Introdução
-
- Padrões de Projetos: Criacionais
- Factory Method
- Abstract Factory
- Prototype
- Singleton
- Builder
Nesta secção, serão apresentados os conceitos básicos de POO, estes conceitos são fundamentais para o entendimento do restante do tutorial, porém se você já é familiarizado com POO, então pode ir direto para a secção de Padrões de Projetos.
Atenção: Para este tutorial, foi utilizado a linguagem de programação Java, com o ambiente de desenvolvimento InteliJ.
Para definirmos bem as funcionalidades de nossos programas, é necessário realizar a abstração do mundo que nossa aplicação irá abranger, por exemplo: Imagine que você irá fazer um sistema simples que calcula a massa corporal de uma pessoa, para essa aplicação serão necessárias abstrair uma especie de objeto no qual você irá trabalhar e que conterá informações como idade, altura e peso atual. Neste objeto, informações como cor do cabelo, gosto musical, CPF e etc, não serão necessários, pois não terão utilidade para sua aplicação. Este processo de mapeamento de informações uteis em um elemento (objeto) coerente com a aplicação se chama de abstração. Dominar o conceito de abstração é algo importante, pois permite ao desenvolvedor formular um cenário mais enxuto e sem distrações.
As classes são projetos de um objetos, na qual encontramos as características e comportamentos que os objetos terão. Analogia: Imagine uma classe como uma receita de bolo em que teremos a massa, cobertura e recheio, todas as propriedades necessárias para fazer nosso bolo estão presentes, ou seja, independente se o bolo será de cobertura de chattily ou morango, massa comum ou de chocolate, sabemos que ele terá uma massa, uma cobertura e um recheio. No contexto da orientação objetos, o bolo feito a partir da nossa receita é um objeto concreto (que já existe), ou seja, quanto nossa receita contem as informações de como nosso bolo deve ser feito, nosso bolo contem as informações do bolo propriamente dito.
Ex. de Classe | Ex. de Objeto |
---|---|
Receita (cobertura, massa) | Bolo (Chocolate, Massa comum) |
Carro (marca, ano) | Gol(Volkswagen, 2015) |
Funcionário (nome, salário) | Gerente(Paulo, R$ 7000,00) |
Em Java, uma classe é formada pelo seguinte comando.
class MeuBolo{
//corpo da classe
}
E um objeto (boloDeChocolate) gerado a partir da classe (MeuBolo) em Java é:
MeuBolo boloDeChocolate = new MeuBolo();
Boa Pratica: Para melhorar a legibilidade do seu código, é recomendado utilizar nomes intuitivos para suas classes, e também utilizar as letras maiúsculas para destacar o inicio de palavras no nome da classes, por exemplo: ExemploDeClasse.
Agora que sabemos o conceito básico de nossa classe, falta adicionar as características dela, também chamadas de atributos no contexto da orientação objetos. No código do bolo apresentado anteriormente, foi mostrado apenas a estrutura básica da classe (em Java), porém não foi apresentado nenhuma das suas características, tais como o massa, cobertura e recheio. Como virmos na seção sobre abstração, estes componentes são muito importantes, pois descrevem virtualmente o que queremos mapear do problema real.
O código para representar um atributo em Java, é formado pelo tipo do atributo e o nome, conforme o código a seguir:
class MeuBolo{
//tipo nome
String massa;
String recheio;
String cobertura;
}
Os atributos, como foi dito anteriormente, correspondem as características da nossa classe, na nossa abstração para o exemplo, o bolo contem apenas três atributos, mas em uma situação real (ou dependendo da sua abstração), poderia haver vários e mais complexos (incluindo outras classes).
Em Java e muitas outras linguagens de programação chamadas de "tipadas", os atributos possuem tipos específicos que devem ser associados ao tipo do dado ao qual o atributo está representando.
Tipo | Descrição | Exemplos |
---|---|---|
byte | Valor pequeno (com tamanho de 8 bits) | de -127 a 127 |
int | Valor inteiro (com tamanho de 32 bits) | 1 , -2 , 2018 |
long | Valor inteiro (com tamanho de 64 bits) | -1 , 2 , -132113 |
float | Valor real (com tamanho de 32 bits) | 1.38, 3.14 , 0.0001 |
double | Valor real (com tamanho de 64 bits) | 1 , 2, 2018 |
char | Caractere (números,letras ou especias) | 'a' , '%' , '1' |
String | Palavras, frases e demais conjuntos de caracteres | "Massa comum" , "Java" , "Chocolate" |
Há outros tipos, porém ficaremos apenas com estes para não confundir. Outra coisa importante sobre os tipos, é que eles podem ser primitivos ou não-primitivos (ou também chamados de compostos), em Java os tipos primitivos iniciam com a primeira letra minuscula, enquanto os tipos não-primitivos iniciam com letra maiúscula. Os tipos primitivos são os componentes básicos (atômicos) das classes, eles correspondem a um espaço de memória com tamanho fixo destinado para armazenar o valor que é atribuído a ele. Enquanto os tipos não-primitivos são compostos por outros atributos, um exemplo de tipo não-primitivo é o String, que contem próprios atributos (conjunto de caracteres do tipo char (letras)) e também carrega seus próprios comportamentos (métodos) que veremos mais a frente. 🏠
Os métodos correspondem ao comportamento da nossa classe. A estrutura básica de um métodos possui um modificador de acesso (veremos isso mais tarde), um tipo de retorno, o nome do método e os parâmetros.
//modificador retorno nome(parametros){bloco de código}
public void meuMetodo(){
//realiza algum processamento
}
Um método pode ou não receber um parâmetro, um parâmetro é um atributo de escopo
e pode ter ou não um retorno, caso não tenha o local do tipo deve conter o termo void.
Boas práticas: Como um método remete a um comportamente, é recomendado usar verbos no infinitivo para nomear-los, por exemplo: calcularValor, descreverBolo, copiarTexto e etc.
Por motivos de segurança, as vezes é preciso restringir a "visualização" de um método ou atributo de uma classe em relação à outras classes.
Exemplo: Imagine que você criou um método que valida a senha de um usuário, e este método é utilizado apenas na classe ContaUsuario, logo ao utilizar um modificador de acesso, você pode, por exemplo, manter o acesso desse método apenas interno da classe em que ele está ou pode liberar para ser usada em qualquer outro lugar da aplicação.
Para definir a visibilidade de atributos e métodos, na maioria das linguagens, exitem palavras-chaves que definem o escopo de acesso. São elas:
Modificador | Descrição |
---|---|
public | acesso liberado para todos que tem acesso à classe (o modificador mais liberado) |
private | acesso exclusivo apenas para a classe (o modificador mais rigoroso) |
protected | acesso para as classes do mesmo pacote ou por meio de herança |
default | acesso para as classes do mesmo pacote |
Para ser usado em atributos ou métodos, deve ser usado da seguinte forma:
//Exemplo do modificador private em um atributo
private String nome = "José Raimundo";
// Exemplo do modificador public em um método
public String getNome(){
return nome;
}
Modificador abstract: usado em métodos e classes, a classe que o usa não pode ser instanciada e a presença de um método abstrato implica que a classe deve ser abstrata também. Este modificador é usado quando o método ainda não está implementado, normalmente quando você tem apenas uma ideia ou esboço de como será a classe/método, bastante comum em classes pai que possuem métodos que ainda não foram implementadas, sendo necessário a implementação nas classes filhas.
Exemplo: Note que na classe Bolo eu não sei ainda o tipo da cobertura que o bolo irá ter, então eu crio um método abstrato que irá conter apenas a ideia de como o método irá se comportar, em seguida eu passo a tarefa da implementação para os filhos.
public abstract class Bolo{
public abstract String cobertura();
}
A classe filha implementa o métodos abstratos do pai.
public class BoloChocolate exends Bolo{
// .... construtor
@Override
public String cobertura(){
return "Cobertura de chocolate";
}
}
Com essa abordagem, eu consigo padronizar minhas várias classes filhas carregando um método com a mesma nomenclature da classe pai.
Quando um variável é criada utilizando o modificador static, significa que o valor daquela variável será o mesmo em todas as instâncias do objeto que a carrega, e se em alguma delas for modificada o valor será modificado em todas.
Por exemplo, observe o valor do atributo nome.
public class Bolo {
public static String nome = "teste";
public Bolo(){ }
}
No código a seguir, note que ao modificar o valor de nome na instância b2, o valor de nome em b1 também é modificado.
public class main {
public static void main(String[] args) {
Bolo b1 = new Bolo();
System.out.println(b1.nome);
//saída: "teste"
Bolo b2 = new Bolo();
b2.nome = "Chocolate";
System.out.println(b1.nome);
// saída: "Chocolate"
System.out.println(b2.nome);
// saída: "Chocolate"
}
}
Este comportamento é constantemente utilizado em aplicações que utilizam o padrão de projeto singleton (veremos mais a frente), pois garante que um valor seja compartilhado por diversos módulos do mesmo projeto, evitando conflitos ou a necessidade de atualizar diversas instâncias da mesma classe.
Atenção: Os exemplos utilizados neste tutorial são exemplos apresentados como exercícios para o curso de Sistemas Para Internet, no Instituto Federal da Paraíba, há possibilidades de conter alguns erros, caso encontre, basta entrar em contato ou você mesmo consertar via Git.
O objetivo do Factory Method é "Definir uma interface para criar um objeto, mas deixar as subclasses decidirem que classe instanciar. O Factory Method permite adiar a instanciação para subclasses."[5].
Imagine que temos que construir um sistema para nossa loja de bolos para gerenciar os nosso produtos e fornecedores, e que atualmente temos três fornecedores especializados em tipos de bolos diferentes. Exemplo:
Fornecedor | Bolo |
---|---|
João Bolos | Bolo de Ovo |
Recanto da vovô | Bolo de Nata |
Sabor nordestino | Bolo Baeta |
Para facilitar o exemplo, vamos criar um interface para dar apoio para nossos produtos (bolos). E nessa interface, iremos criar apenas um método que escreve na tela a descrição do bolo.
public interface Bolo{
void descreverBolo();
}
Agora que temos nossa informação e a representação (interface) da nossa classe bolo, vamos direto ao ponto. Como foi visto anteriormente, o Factory Method define uma interface (ou uma classe abstrata, caso as subclasses precisem de mais recursos [4] que possibilita adiar a instanciação. Para isso, precisaremos criar uma interface que será usada pelos responsáveis pela criação do produto.
Interface do fornecedor de bolo.
public interface FornecedorDeBolo{
Bolo criaBolo();
}
Exemplo de produto/classe concreta (nosso bolo)
public class BoloDeNata implements Bolo{
@Override
void descreverBolo(){
System.out.print("Bolo de nata da Vovó");
}
}
Exemplo de fornecedor, note que fornecedor carrega a responsabilidade de criar o seu respectivo produto (bolo).
public class RecantoDaVovo implements ForncedorDeBolo{
@Override
public Bolo criaBolo(){
return new BoloDeNata();
}
}
Classe de teste com o método main
public static void main(String[] args){
FornecedorDeBolo vovo = new RecantoDaVovo();
Bolo bolo = vovo.criaBolo();
}
Com essa abordagem, temos os seguintes:
- Pontos positivos -- Aumenta a facilidade para inserção novos produtos sem necessitar alterar o código da aplicação. -- Segue o principio da responsabilidade única.
- Pontos negativos -- Requer a criação de muitas estruturas.
Com o Abstract Factory, é buscado "fornecer um interface para criação de famílias de objetos relacionados ou dependentes sem especificar suas classes concretas"[4]. Em outras palavras, o Abstract Factory é utilizado para criar grupos de objetos que possuem algum relacionamento. Vamos ao exemplo:
Fornecedor | Bolo | Tipo da massa |
---|---|---|
Sabor nordestino | Bolo Baeta | Normal |
Sabor nordestino | Bolo de Cenoura | Diet |
Recanto da vovô | Bolo de Nata | Normal |
Recanto da vovô | Bolo de aveia | Diet |
Note que agora, além dos bolos tradicionais, também há bolos feitos com receitas diets, note também que um fornecedor pode fornecer mais de um bolo (logo, os bolos que pertence a um fornecedor especifico, passam a fazer parte de um grupo/família). Um abordagem convencional seria criar uma classe para cada bolo (ou utilizando Factory Method), mas como saber a qual agrupamento que um determinado objeto (bolo) pertence? Ao fornecer uma interface para criar os grupos de objetos, o Abstract Factory resolve este problema permitindo a criação de um ramo em comum na hierarquia, o que possibilita a identificação de um grupo de objetos.
Interface com a criação de famílias de objetos.
public interface FornecedorDeBolo{
public BoloDiet criaBoloDiet();
public BoloNormal criaBoloNormal();
}
Exemplo de fornecedor criando seus vários produtos.
public class RecantoDaVovo implements FornecedorDeBolo{
public BoloDiet criaBoloDiet(){
return new BoloDeAveia();
}
public BoloNormal criaBoloNormal(){
return new BoloDeNata();
}
}
Exemplo de interface para padronização da criação de produtos (bolos). Note que o Abstract Factory é bastante semelhante ao Factory Method, apenas com uma pequena particularidade. Essa sensação de repetição de padrão é comum.
public interface BoloNormal(){
public void descreverBoloNormal();
}
public interface BoloDiet(){
public void descreverBoloDiet();
}
Exemplo de produto do grupo de bolos normais (bolo).
public class BoloDeNata implements BoloNormal{
@override
public void descreverBoloNormal(){
System.out.println("Bolo de nata da vovó, bolo normal!");
}
}
Exemplo de produto do grupo de bolos diet (bolo).
public class BoloDeAveia implements BoloDiet{
@override
public void descreverBoloDiet(){
System.out.println("Bolo de aveia da vovó, bolo diet!");
}
}
Agora chamaremos nossa classe de teste. No que com essa abordagem, conseguirmos amarrar um grupo de objetos (bolos) ao seu ramo em comum (neste caso, o fornecedor).
public static void main(String[] args) {
FornecedorDeBolo fornecedor = new RecantoDaVovo();
fornecedor = new RecantoDaVovo();
BoloDiet bolo_diet = fornecedor.criaBoloDiet();
BoloNormal bolo_normal = fornecedor.criaBoloNormal();
bolo_diet.descreverBoloDiet();
bolo_normal.descreverBoloNormal();
}
Com essa abordagem, temos os seguintes:
- Pontos positivos -- A classe responsável pelo uso da nossa Abstract Factory fica dependente de uma interface simples e pequena. -- Segue o principio da segregação de interface.
- Pontos negativos -- Requer a criação de muitas estruturas.
O padrão Builder visa “Separar a construção de um objeto complexo de sua representação de modo que o mesmo processo de construção possa criar diferentes representações.”[4]. Com esse padrão, a construção do objeto é separada em vários métodos que realizam o trabalho pesado e devolvem um objeto complexo pronto para ser usado.
Para esse exemplo, suponhamos que agora o nosso produto é um pouco mais complexo.
Fornecedor | Bolo | Massa | Cobertura | Recheio |
---|---|---|---|---|
Sabor nordestino | Bolo de Ovo | Normal | Brigadeiro | Chocolate |
Recanto da vovô | Bolo de Cenoura | Diet | Coco | Morango |
Para isso, vamos criar uma estrutura de bolo diferente.
public class Bolo{
private String nome;
private String massa;
private String recheio;
private String cobertura;
//aqui vai o construtor padrão, os get e set e toString
}
Nossa classe builder terá um método para construir cada dado no nosso produto (bolo), aqui teremos um objeto bolo que será reaproveitado/construído pelas subclasses por meio dos métodos abstratos.
public abstract class BoloBuilder{
protected Bolo bolo;
public BoloBuilder(){
bolo = new Bolo();
}
public abstract void builderNome();
public abstract void builderMassa();
public abstract void builderRecheio();
public abstract void builderCobertura();
public Bolo getBolo(){
return this.bolo;
}
}
Exemplo de classe Builder concreta:
public class RecantoDaVovo extends BoloBuilder{
@override
public void builderNome(){
this.bolo.setNome("Bolo de Cenoura");
}
@override
public void builderMassa(){
this.bolo.setMassa("Diet");
}
@override
public void builderRecheio(){
this.bolo.setRecheio("Morango");
}
@override
public void builderCobertura(){
this.bolo.setCobertura("Coco");
}
}
Note que dentro de cada método poderia haver operações mais complexas, como por exemplo, buscar informações em um banco de dados, realizar cálculos matemáticos complexos, criação de outros objetos e etc.
Agora que criamos nosso builder e um exemplo de classe concreta, precisamos criar a classe responsável gerenciar a construção do nosso objeto, esta classe recebe no seu construtor um objeto do tipo BoloBuilder (ou seja, ConstrutorDirector pode manipular qualquer classe que herda de BoloBuilder).
public classe ConstrutorDirector{
private BoloBuilder fornecedor;
public ConstrutorBidrector(BoloBuilder fornecedor){
this.fornecedor = fornecedor;
}
public void construirBolo(){
this.fornecedor.builderNome();
this.fornecedor.builderMassa();
this.fornecedor.builderRecheio();
this.fornecedor.builderCobertura();
}
public Bolo getBolo(){
return this.fornecedor.getBolo();
}
}
Agora criaremos nossa classe para testar.
public static void main(String[] agrs){
BoloBuilder fornecedor = new RecantoDaVovo();
ConstrutorDirector construtor = new ConstrutorDirector(fornecedor);
construtor.construirBolo();
Bolo bolo = construtor.getBolo();
System.out.println(bolo);
}
Note que agora nossa classe de teste vai lidar apenas com o código do construtor e note também que para criar um novo objeto só precisamos utilizar um novo builder (atribuindo-o para o objeto fornecedor).
Com essa abordagem, temos os seguintes:
- Pontos positivos -- Encobre a construção de um objeto, deixando um código mais enxuto para quem vai usar. -- Separa a construção de um objeto complexo em parte menores.
- Pontos negativos -- Não há o conceito de famílias de produtos como no Abstract Factory.
O Prototype “Especificar tipos de objetos a serem criados usando uma instância protótipo e criar novos objetos pela cópia desse protótipo.”[4]. Em outras palavras, esse padrão utiliza uma classe "esqueleto" para criar um clone completo.
Bolo | Tipo da massa |
---|---|
Bolo Baeta | Normal |
Bolo de Cenoura | Diet |
Bolo de Nata | Normal |
Primeiro criamos nosso esqueleto (protótipo) de classe, note que é uma classe abstrata com poucas implementações completa.
public abstract class BoloPrototype{
protected String tipo_massa;
public abstract descreverBolo();
public abstract clonarBolo();
public void setTipoMassa(String tipo_massa){
this.tipo_massa = tipo_massa;
}
public String getTipoMassa(){
return this.tipo_massa;
}
}
Agora criaremos a classe concreta
public class BoloBaeta extends BoloPrototype{
protected BoloBaeta(BoloBaeta bolo_baeta){
this.tipo_massa = bolo_baeta.getTipoMassa();
}
public BoloBaeta(){
tipo_massa = "Diet";
}
@Override
public String descreverBolo(){
return "Bolo baeta com massa " + getTipoMassa();
}
@Override
public BoloPrototype clonarBolo(){
return new BoloBaeta(this);
}
}
[1]https://www.caelum.com.br/apostila-java-orientacao-objetos/orientacao-a-objetos-basica/
[2]https://sourcemaking.com/design_patterns
[3]https://www.devmedia.com.br/introducao-a-programacao-orientada-a-objetos-em-java/26452
[4]https://brizeno.wordpress.com/2011/09/17/mao-na-massa-factory-method/
[5]GAMMA, Erich et al. Padrões de Projeto: Soluções reutilizáveis de software orientado a objetos.
[6]https://en.wikipedia.org/wiki/SOLID_(object-oriented_design)