Os princípios de design SOLID são os princípios de design mais importantes que você precisa saber para escrever código limpo. Ter um domínio sólido sobre os princípios SOLID é uma habilidade indispensável para qualquer programador. Eles são a base sobre a qual outros padrões de projeto são desenvolvidos.
Neste artigo, abordaremos os princípios de design SOLID usando alguns exemplos da vida real e compreenderemos sua importância.
Juntamente com Polimorfismo, Abstração e Herança, os Princípios SOLID são realmente importantes para ser bom em programação orientada a objetivos.
Os princípios SOLID são importantes por vários motivos:
Vamos agora explorar cada um dos princípios do SOLID detalhadamente com exemplos do mundo real.
S nos princípios SOLID significa Princípio de Responsabilidade Única. O Princípio da Responsabilidade Única afirma que uma classe deve ter apenas um único motivo para mudar. Isso limita o número de locais que precisamos fazer alterações ao incorporar requisitos adicionais em nosso projeto.
Cada classe deve ter exatamente um motivo para mudar.
Por exemplo, digamos que estamos projetando um aplicativo bancário em Java onde temos uma classe SavingsAccount
que permite operações básicas como débito, crédito e sendUpdates
. O método sendUpdate
pega um enum chamado NotificationMedium
(como Email, SMS, etc.) e envia a atualização com a mídia apropriada. Escreveremos o código para isso conforme mostrado abaixo.
public class SavingsAccount { public int balance; public String name; public SavingsAccount(int initialBalance, String name) { this.balance = initialBalance; this.name = name; System.out.println("Created a savings account with balance = " + initialBalance); } public void debit(int amountToDebit) { // debit business logic } public void credit(int amountToCredit) { // credit business logic } public void sendNotification(NotificationMedium medium) { if (medium == NotificationMedium.SMS) { // Send SMS here } else if (medium == NotificationMedium.EMAIL) { // Send Email here } } }
public enum NotificationMedium { SMS, EMAIL }
Agora, se você observar a classe SavingsAccount
acima, ela pode mudar por vários motivos:
Se houver alguma alteração na lógica central da classe SavingsAccount
(como debit
, credit
, etc.).
Se o banco decidir introduzir um novo meio de notificação (digamos WhatsApp).
Isto é uma violação do Princípio da Responsabilidade Única nos Princípios SOLID. Para consertar, faremos uma classe separada que envia a notificação.
Vamos refatorar o código acima de acordo com os Princípios SOLID
public class SavingsAccount { public int balance; public String name; public SavingsAccount(int initialBalance, String name) { this.balance = initialBalance; this.name = name; System.out.println("Created a savings account with balance = " + initialBalance); } public void debit(int amountToDebit) { // debit business logic } public void credit(int amountToCredit) { // credit business logic } public void printBalance() { System.out.println("Name: " + name+ " Account Balance: " + balance); } public void sendNotification(Medium medium) { Sender.sendNotification(medium, this); } }
public enum NotificationMedium { SMS, EMAIL }
public class Sender { public static void sendNotification(NotificationMedium medium, SavingsAccount account) { // extract account data from the account object if (medium == NotificationMedium.SMS) { //logic to send SMS here } else if (medium == NotificationMedium.EMAIL) { // logic to send Email here } } }
Agora, como refatoramos o código, se houver alguma alteração no NotificationMedium
ou no formato, alteraremos a classe Sender
. Porém, se houver uma alteração na lógica central de SavingsAccount
, haverá alterações na classe SavingsAccount
.
Isso corrige a violação que observamos no primeiro exemplo.
O princípio Open Close afirma que devemos projetar as classes de tal forma que elas estejam abertas para extensão (no caso de adicionar recursos adicionais), mas fechadas para modificação. Estar fechado para modificação nos ajuda de duas maneiras:
Para ver um exemplo do princípio Open Close, vamos dar uma olhada no exemplo de um carrinho de compras (como aquele implementado em sites de comércio eletrônico).
Vou criar uma classe chamada Cart
que conterá uma lista de Item
que você pode adicionar a ela. Dependendo do tipo de item e da tributação sobre ele queremos criar um método que calcule o valor total do carrinho dentro da classe Cart
.
As aulas devem ser abertas para extensão e fechadas para modificação
import java.util.ArrayList; import java.util.List; public class Cart { private List<Item> items; public Cart() { this.items = new ArrayList<>(); } public void addToCart(Item item) { items.add(item); } public double calculateCartValue() { double value = 0.0; for(Item item: items) { if (item.getItemType() == GIFT) { // 8% tax on gift + 2% gift wrap cost value += (item.getValue()*1.08) + item.getValue()*0.02 } else if (item.getItemType() == ItemType.ELECTRONIC_ITEM) { value += (item.getValue()*1.11); } else { value += item.getValue()*1.10; } } return value; } }
@Getter @Setter public abstract class Item { protected double price; private ItemType itemType; public double getValue() { return price; } }
public enum ItemType { ELECTRONIC, GIFT }
No exemplo acima, o método calculateCartValue
calcula o valor do carrinho iterando todos os itens dentro do carrinho e invocando a lógica com base no tipo de item.
Embora este código pareça correto, ele viola os Princípios SOLID.
Digamos que precisamos adicionar uma nova regra para um tipo diferente de item (por exemplo, Mercearia) ao calcular o valor do carrinho. Nesse caso, teríamos que modificar a classe original Cart
e escrever outra condição else if
dentro dela que verifica itens do tipo Grocery
.
No entanto, com pouca refatoração, podemos fazer com que o código siga o princípio abrir/fechar. Vamos ver como.
Primeiro, tornaremos a classe Item
abstrata e criaremos classes concretas para diferentes tipos de Item
(s), conforme mostrado abaixo.
public abstract class Item { protected double price; public double getValue() { return price; } }
public class ElectronicItem extends Item { public ElectronicItem(double price) { super.price = price; } @Override public double getValue() { return super.getValue()*1.11; } }
public class GiftItem extends Item { public GiftItem(double price) { super.price = price; } @Override public double getValue() { return super.getValue()*1.08 + super.getValue()*0.02; } }
public class GroceryItem extends Item { public GroceryItem(double price) { super.price = price; } @Override public double getValue() { return super.getValue()*1.03; } }
Dentro de cada classe de Item concreta, como GroceryItem
, GiftItem
e ElectronicItem
, implemente o método getValue()
que contém a lógica de negócios para tributação e cálculo de valor.
Agora, faremos com que a classe Cart
dependa da classe abstrata Item
e invocaremos o método getValue()
para cada item conforme mostrado abaixo.
import java.util.ArrayList; import java.util.List; public class Cart { private List<Item> items; public Cart(Payment paymentOption) { this.items = new ArrayList<>(); } public void addToCart(Item item) { items.add(item); } public double calculateCartValue() { double value = 0.0; for(Item item: items) { value += item.getValue(); } return value; } }
Agora, neste código refatorado, mesmo que novos tipos de Item
(s) sejam introduzidos, a classe Cart
permanece inalterada. Devido ao polimorfismo, qualquer que seja o tipo real do Item
dentro dos items
ArrayList
, o método getValue()
dessa classe seria invocado.
O princípio de substituição de Liskov afirma que em um determinado código, mesmo que substituamos o objeto de uma superclasse por um objeto da classe filha, o código não deve quebrar. Em outras palavras, quando uma subclasse herda uma superclasse e substitui seus métodos, ela deve manter consistência com o comportamento do método na superclasse.
Por exemplo, se fizermos as seguintes classes Vehicle
e duas classes Car
e Bicycle
. Agora, digamos que criamos um método chamado startEngine()
dentro da classe Vehicle, ele pode ser substituído na classe Car
, mas não será suportado na classe Bicycle
porque Bicycle
não tem motor (veja o exemplo de código abaixo)
A subclasse deve manter consistência com o comportamento da superclasse ao substituir métodos.
class Vehicle { public void startEngine() { // start engine of the vehicle } } class Car extends Vehicle { @Override public void startEngine() { // Start Engine } } class Bicycle extends Vehicle { @Override public void startEngine(){ throw new UnsupportedOperationException("Bicycle doesn't have engine"); } }
Agora, digamos que exista algum código que espera um objeto do tipo veículo e depende do método startEngine()
. Se, ao chamar esse trecho de código, em vez de passar um objeto do tipo Vehicle
, passarmos um objeto Bicycle
, isso causaria problemas no código. Já que o método da classe Bicycle
(s) lançará uma exceção quando o método startEngine()
for chamado. Isto seria uma violação dos Princípios SOLID (princípio de substituição de Liskov)
Para resolver esse problema, podemos criar duas classes MotorizedVehicle
e NonMotorizedVehicle
e fazer com que Car
herde da classe MotorizedVehicle
e que Bicycle
herde de NonMotorizedVehicle
class Vehicle { } class MotorizedVehicle extends Vehicle { public void startEngine() { // start engine here } } class Car extends MotorizedVehicle { @Override public void startEngine() { // Start Engine } } class NonMotorizedVehicle extends Vehicle { public void startRiding() { // Start without engine } } class Bicycle extends NonMotorizedVehicle { @Override public void startRiding(){ // Start riding without the engine. } }
O “I” em Princípios SOLID significa Princípio de Segregação de Interface.
O princípio de segregação de interface afirma que, em vez de ter interfaces maiores que forçam a implementação de classes a implementar métodos não utilizados, devemos ter interfaces menores e ter classes implementadas. Dessa forma, as classes implementam apenas os métodos relevantes e permanecem limpas.
Divida suas interfaces em várias interfaces menores, em vez de uma interface grande.
Por exemplo, vejamos a estrutura interna de coleções em Java. Entre outras estruturas de dados, Java também fornece estruturas de dados LinkedList
e ArrayList
,
A classe ArrayList
implementa as seguintes interfaces: Serializable
, Cloneable
, Iterable
, Collection
, List
e RandomAccess
.
A classe LinkedList
implementa Serializable
, Cloneable
, Iterable
, Collection
, Deque
, List
e Queue
.
São muitas interfaces!
Em vez de ter tantas interfaces, os desenvolvedores Java poderiam ter combinado Serializable
, Cloneable
, Iterable
, Collecton
, List
e RandomAccess
em uma interface, digamos a interface IList
. Agora, ambas as classes ArrayList
e LinkedList
poderiam ter implementado esta nova interface IList
.
No entanto, como LinkedList
não suporta acesso aleatório, ele poderia ter implementado os métodos na interface RandomAccess
e poderia ter lançado UnsupportedOperationException
quando alguém tentasse chamá-lo.
No entanto, isso seria uma violação do princípio de segregação de interface nos Princípios SOLID, pois “forçaria” a classe LinkedList a implementar os métodos dentro da interface RandomAccess
, mesmo que não seja obrigatório.
Portanto, é melhor dividir a interface com base no comportamento comum e deixar que cada classe implemente muitas interfaces em vez de uma interface grande.
O Princípio da Inversão de Dependência afirma que as classes de nível superior não devem depender diretamente das classes de nível inferior. Isso causa um acoplamento forte entre os dois níveis.
Em vez disso, as classes inferiores deveriam fornecer uma interface da qual as classes de nível superior deveriam depender.
Depende de interfaces em vez de classes
Por exemplo, vamos continuar com o exemplo do Cart
que vimos acima e aprimorá-lo para adicionar algumas opções de pagamento. Vamos supor que temos dois tipos de opções de pagamento conosco, DebitCard
e Paypal
. Agora, na classe Cart
, queremos adicionar um método ao placeOrder
que calcularia o valor do carrinho e iniciaria o pagamento com base no pagamento fornecido. método.
Para fazer isso, poderíamos ter adicionado dependência no exemplo Cart
acima, adicionando as duas opções de pagamento como campos dentro da classe Cart
. No entanto, isso uniria fortemente a classe Cart
com as classes DebitCard
e Paypal
.
Em vez disso, criaríamos uma interface Payment
e faríamos com que as classes DebitCard
e Paypal
implementassem as interfaces Payment
. Agora, a classe Cart
dependerá da interface Payment
, e não dos tipos de pagamento individuais. Isso mantém as classes fracamente acopladas.
Veja o código abaixo.
public interface Payment { void doPayment(double amount); } public class PaypalPayment implements Payment { @Override public void doPayment(double amount) { // logic to initiate paypal payment } } public class DebitCardPayment implements Payment { @Override public void doPayment(double amount) { // logic to initiate payment via debit card } } import java.util.ArrayList; import java.util.List; public class Cart { private List<Item> items; private Payment paymentOption; public Cart(Payment paymentOption) { this.items = new ArrayList<>(); this.paymentOption = paymentOption; } public void addToCart(Item item) { items.add(item); } public double calculateCartValue() { double value = 0.0; for(Item item: items) { value += item.getValue(); } return value; } public void placeOrder() { this.paymentOption.doPayment(calculateCartValue()); } }
Se você estiver interessado em aprender mais sobre programação orientada a objetos, padrões de design GoF e entrevistas de design de baixo nível, verifique meu curso altamente avaliado Programação Orientada a Objetos + Padrões de Design Java