class Book {
String name;
String authorName;
int year;
int price;
String isbn;
public Book(String name, String authorName, int year, int price, String isbn) {
this.name = name;
this.authorName = authorName;
this.year = year;
this.price = price;
this.isbn = isbn;
}
}
Esta é uma aula de livro simples com alguns campos. Nada chique. Não estou tornando os campos privados para que não precisemos lidar com getters e setters e possamos nos concentrar na lógica.
Agora vamos criar a classe de nota fiscal que conterá a lógica de criação da nota fiscal e cálculo do preço total. Por enquanto, suponha que nossa livraria venda apenas livros e nada mais.
public class Invoice {
private Book book;
private int quantity;
private double discountRate;
private double taxRate;
private double total;
public Invoice(Book book, int quantity, double discountRate, double taxRate) {
this.book = book;
this.quantity = quantity;
this.discountRate = discountRate;
this.taxRate = taxRate;
this.total = this.calculateTotal();
}
public double calculateTotal() {
double price = ((book.price - book.price * discountRate) * this.quantity);
double priceWithTaxes = price * (1 + taxRate);
return priceWithTaxes;
}
public void printInvoice() {
System.out.println(quantity + "x " + book.name + " " + book.price + "$");
System.out.println("Discount Rate: " + discountRate);
System.out.println("Tax Rate: " + taxRate);
System.out.println("Total: " + total);
}
public void saveToFile(String filename) {
// Creates a file with given name and writes the invoice
}
}Aqui está nossa classe de fatura. Ele também contém alguns campos sobre faturamento e 3 métodos:
- calcularTotal método, que calcula o preço total,
- imprimir fatura método, que deve imprimir a fatura para o console, e
- salvar em arquivo método, responsável por gravar a fatura em um arquivo.
Você deve se dar um segundo para pensar sobre o que há de errado com esse design de classe antes de ler o próximo parágrafo.
Ok, então o que está acontecendo aqui? Nossa classe viola o Princípio da Responsabilidade Única de várias maneiras.
A primeira violação é a imprimir fatura método, que contém nossa lógica de impressão. O SRP afirma que nossa classe deve ter apenas um único motivo para mudar, e esse motivo deve ser uma mudança no cálculo da fatura para nossa classe.
Mas nesta arquitetura, se quiséssemos mudar o formato de impressão, precisaríamos mudar a classe. É por isso que não devemos ter a lógica de impressão misturada com a lógica de negócios na mesma classe.
Existe outro método que viola o SRP em nossa classe: o salvar em arquivo método. Também é um erro extremamente comum misturar lógica de persistência com lógica de negócios.
Não pense apenas em termos de escrever em um arquivo – pode ser salvar em um banco de dados, fazer uma chamada de API ou outras coisas relacionadas à persistência.
Então, como podemos corrigir essa função de impressão, você pode perguntar.
Podemos criar novas classes para nossa impressão e lógica de persistência, portanto, não precisaremos mais modificar a classe de fatura para esses fins.
Criamos 2 classes, InvoicePrinter e InvoicePersistence, e mova os métodos.
public class InvoicePrinter {
private Invoice invoice;
public InvoicePrinter(Invoice invoice) {
this.invoice = invoice;
}
public void print() {
System.out.println(invoice.quantity + "x " + invoice.book.name + " " + invoice.book.price + " $");
System.out.println("Discount Rate: " + invoice.discountRate);
System.out.println("Tax Rate: " + invoice.taxRate);
System.out.println("Total: " + invoice.total + " $");
}
}public class InvoicePersistence {
Invoice invoice;
public InvoicePersistence(Invoice invoice) {
this.invoice = invoice;
}
public void saveToFile(String filename) {
// Creates a file with given name and writes the invoice
}
}Agora, nossa estrutura de classes obedece ao Princípio de Responsabilidade Única e cada classe é responsável por um aspecto de nossa aplicação. Ótimo!
Princípio aberto-fechado
O princípio aberto-fechado requer que as classes devem ser abertas para extensão e fechadas para modificação.
Modificação significa alterar o código de uma classe existente e extensão significa adicionar nova funcionalidade.
Portanto, o que este princípio quer dizer é: Devemos ser capazes de adicionar novas funcionalidades sem tocar no código existente para a classe. Isso porque sempre que modificamos o código existente, corremos o risco de criar possíveis bugs. Portanto, devemos evitar tocar no código de produção testado e confiável (principalmente), se possível.
Mas como vamos adicionar novas funcionalidades sem tocar na classe, você pode perguntar. Geralmente é feito com a ajuda de interfaces e classes abstratas.
Agora que cobrimos os fundamentos do princípio, vamos aplicá-lo ao nosso aplicativo de fatura.
Digamos que nosso chefe venha até nós e diga que deseja que as faturas sejam salvas em um banco de dados para que possamos pesquisá-las facilmente. Nós pensamos bem, isso é fácil, chefe fácil, me dê um segundo!
Criamos o banco de dados, nos conectamos a ele e adicionamos um método de salvamento ao nosso InvoicePersistence classe:
public class InvoicePersistence {
Invoice invoice;
public InvoicePersistence(Invoice invoice) {
this.invoice = invoice;
}
public void saveToFile(String filename) {
// Creates a file with given name and writes the invoice
}
public void saveToDatabase() {
// Saves the invoice to database
}
}Infelizmente, nós, como desenvolvedores preguiçosos da livraria, não projetamos as classes para serem facilmente estendidas no futuro. Portanto, para adicionar esse recurso, modificamos o InvoicePersistence classe.
Se nosso design de classe obedecesse ao princípio Aberto-Fechado, não precisaríamos alterar essa classe.
Portanto, como o desenvolvedor preguiçoso, mas inteligente, da livraria, vemos o problema de design e decidimos refatorar o código para obedecer ao princípio.
interface InvoicePersistence {
public void save(Invoice invoice);
}Mudamos o tipo de InvoicePersistence para Interface e adicione um método de salvamento. Cada classe de persistência implementará este método de salvamento.
public class DatabasePersistence implements InvoicePersistence {
@Override
public void save(Invoice invoice) {
// Save to DB
}
}public class FilePersistence implements InvoicePersistence {
@Override
public void save(Invoice invoice) {
// Save to file
}
}Portanto, nossa estrutura de classe agora se parece com isto:
Agora, nossa lógica de persistência é facilmente extensível. Se nosso chefe nos pedir para adicionar outro banco de dados e tiver 2 tipos diferentes de bancos de dados, como MySQL e MongoDB, podemos fazer isso facilmente.
Você pode pensar que poderíamos simplesmente criar várias classes sem uma interface e adicionar um método save para todas elas.
Mas digamos que estendamos nosso aplicativo e tenhamos várias classes de persistência como InvoicePersistence, BookPersistence e nós criamos um PersistenceManager classe que gerencia todas as classes de persistência:
public class PersistenceManager {
InvoicePersistence invoicePersistence;
BookPersistence bookPersistence;
public PersistenceManager(InvoicePersistence invoicePersistence,
BookPersistence bookPersistence) {
this.invoicePersistence = invoicePersistence;
this.bookPersistence = bookPersistence;
}
}Agora podemos passar qualquer classe que implemente o InvoicePersistence interface para esta classe com a ajuda de polimorfismo. Essa é a flexibilidade que as interfaces fornecem.
Princípio de Substituição Liskov
O Princípio de Substituição de Liskov afirma que as subclasses devem ser substituíveis por suas classes básicas.
Isso significa que, dado que a classe B é uma subclasse da classe A, devemos ser capazes de passar um objeto da classe B para qualquer método que espere um objeto da classe A e o método não deve dar nenhuma saída estranha nesse caso.
Este é o comportamento esperado, porque quando usamos herança assumimos que a classe filha herda tudo o que a superclasse possui. A classe filha estende o comportamento, mas nunca o restringe.
Portanto, quando uma classe não obedece a esse princípio, isso leva a alguns bugs desagradáveis que são difíceis de detectar.
O princípio de Liskov é fácil de entender, mas difícil de detectar no código. Então, vamos ver um exemplo.
class Rectangle {
protected int width, height;
public Rectangle() {
}
public Rectangle(int width, int height) {
this.width = width;
this.height = height;
}
public int getWidth() {
return width;
}
public void setWidth(int width) {
this.width = width;
}
public int getHeight() {
return height;
}
public void setHeight(int height) {
this.height = height;
}
public int getArea() {
return width * height;
}
}Temos uma classe Rectangle simples e um getArea função que retorna a área do retângulo.
Agora decidimos criar outra classe para Squares. Como você deve saber, um quadrado é apenas um tipo especial de retângulo onde a largura é igual à altura.
class Square extends Rectangle {
public Square() {}
public Square(int size) {
width = height = size;
}
@Override
public void setWidth(int width) {
super.setWidth(width);
super.setHeight(width);
}
@Override
public void setHeight(int height) {
super.setHeight(height);
super.setWidth(height);
}
}Nossa classe Square estende a classe Rectangle. Definimos altura e largura com o mesmo valor no construtor, mas não queremos que nenhum cliente (alguém que usa nossa classe em seu código) altere a altura ou o peso de uma forma que possa violar a propriedade square.
Portanto, substituímos os configuradores para definir as duas propriedades sempre que uma delas for alterada. Mas, ao fazer isso, violamos o princípio da substituição de Liskov.
Vamos criar uma classe principal para realizar testes no getArea função.
class Test {
static void getAreaTest(Rectangle r) {
int width = r.getWidth();
r.setHeight(10);
System.out.println("Expected area of " + (width * 10) + ", got " + r.getArea());
}
public static void main(String[] args) {
Rectangle rc = new Rectangle(2, 3);
getAreaTest(rc);
Rectangle sq = new Square();
sq.setWidth(5);
getAreaTest(sq);
}
}O testador de sua equipe acabou de criar a função de teste getAreaTest e diz que o seu getArea função não passa no teste de objetos quadrados.
No primeiro teste, criamos um retângulo onde a largura é 2 e a altura é 3 e chamamos getAreaTest. A saída é 20 conforme o esperado, mas as coisas dão errado quando passamos pelo quadrado. Isso ocorre porque a chamada para setHeight A função no teste também define a largura e resulta em uma saída inesperada.
Princípio de Segregação de Interface
Segregação significa manter as coisas separadas, e o Princípio de Segregação de Interface trata de separar as interfaces.
O princípio afirma que muitas interfaces específicas do cliente são melhores do que uma interface de uso geral. Os clientes não devem ser forçados a implementar uma função de que não precisam.
Este é um princípio simples de entender e aplicar, então vamos ver um exemplo.
public interface ParkingLot {
void parkCar(); // Decrease empty spot count by 1
void unparkCar(); // Increase empty spots by 1
void getCapacity(); // Returns car capacity
double calculateFee(Car car); // Returns the price based on number of hours
void doPayment(Car car);
}
class Car {
}Modelamos um estacionamento muito simplificado. É o tipo de estacionamento onde você paga uma taxa por hora. Agora considere que queremos implantar um estacionamento gratuito.
public class FreeParking implements ParkingLot {
@Override
public void parkCar() {
}
@Override
public void unparkCar() {
}
@Override
public void getCapacity() {
}
@Override
public double calculateFee(Car car) {
return 0;
}
@Override
public void doPayment(Car car) {
throw new Exception("Parking lot is free");
}
}Nossa interface de estacionamento era composta de 2 coisas: lógica relacionada ao estacionamento (estacionar, desestacionar, obter capacidade) e lógica relacionada ao pagamento.
Mas é muito específico. Por causa disso, nossa classe FreeParking foi forçada a implementar métodos de pagamento que são irrelevantes. Vamos separar ou segregar as interfaces.
Agora separamos o estacionamento. Com este novo modelo, podemos ir ainda mais longe e dividir o PaidParkingLot para suportar diferentes tipos de pagamento.
Agora nosso modelo é muito mais flexível, extensível e os clientes não precisam implementar nenhuma lógica irrelevante porque fornecemos apenas funcionalidades relacionadas ao estacionamento na interface do estacionamento.
Princípio de Inversão de Dependência
O princípio de Inversão de Dependência afirma que nossas classes devem depender de interfaces ou classes abstratas em vez de classes e funções concretas.
No dele artigo (2000), Tio Bob resume este princípio da seguinte forma:
“Se o OCP declara o objetivo da arquitetura OO, o DIP declara o mecanismo primário”.
Esses dois princípios estão de fato relacionados e já aplicamos esse padrão antes, enquanto discutíamos o princípio aberto-fechado.
Queremos que nossas classes sejam abertas à extensão, portanto, reorganizamos nossas dependências para depender de interfaces em vez de classes concretas. Nossa classe PersistenceManager depende de InvoicePersistence em vez das classes que implementam essa interface.
Conclusão
Neste artigo, começamos com a história dos princípios SOLID e, em seguida, tentamos adquirir uma compreensão clara dos por que e como cada princípio. Nós até mesmo refatoramos um aplicativo de fatura simples para obedecer aos princípios SOLID.
Quero agradecer a você por ter lido todo o artigo e espero que os conceitos acima estejam claros.
Sugiro manter esses princípios em mente ao projetar, escrever e refatorar seu código para que ele seja muito mais limpo, extensível e testável.
Se você estiver interessado em ler mais artigos como este, você pode se inscrever no meu do blog lista de discussão para ser notificado quando eu publicar um novo artigo.
