Em termos gerais, todas as linguagens de programação podem ser classificadas em dois paradigmas:
Programação imperativa / orientada a objetos - Segue a sequência de instruções que são executadas linha por linha.
Programação declarativa/ funcional - Não é sequencial, mas se preocupa mais com o objetivo do programa. Todo o programa é como uma função que possui subfunções, cada uma executando uma determinada tarefa.
Como desenvolvedor júnior, estou percebendo da maneira mais difícil (leia-se: eu estava olhando nervosamente para 1000 linhas de código) que não se trata apenas de escrever código funcional, mas também de código semanticamente fácil e flexível.
Embora existam várias práticas recomendadas para escrever código limpo em ambos os paradigmas, falarei sobre os princípios de design SOLID relativos ao paradigma de programação orientado a objetos.
S - Responsabilidade Única
O—Princípio Aberto-Fechado
L — Princípio da Substituição de Liskov
I — Segregação de interface
D - Inversão de Dependência
A principal razão para qualquer dificuldade em entender esses conceitos não é porque suas profundidades técnicas são insondáveis, mas porque são diretrizes abstratas que são generalizadas para escrever código limpo na programação orientada a objetos. Vejamos alguns diagramas de classe de alto nível para esclarecer esses conceitos.
Estes não são diagramas de classe precisos, mas são esquemas básicos para ajudar a entender quais métodos estão presentes em uma classe.
Vamos considerar um exemplo de café ao longo do artigo.
Uma classe deve ter apenas um motivo para mudar
Considere esta classe que lida com pedidos online recebidos pelo café.
O que há de errado com isso?
Essa única classe é responsável por várias funções. E se você tiver que adicionar outros métodos de pagamento? E se você tiver várias maneiras de enviar a confirmação? Alterar a lógica de pagamento na classe responsável pelo processamento dos pedidos não é um projeto muito bom. Isso leva a um código altamente não flexível.
Uma maneira melhor seria separar essas funcionalidades específicas em classes concretas e chamar uma instância delas, conforme mostrado na ilustração abaixo.
As entidades devem estar abertas para extensão, mas fechadas para modificação.
No café, você deve escolher os condimentos para o seu café em uma lista de opções e há uma aula que trata disso.
O café decidiu adicionar um novo condimento, a manteiga. Observe como o preço vai mudar de acordo com o condimento selecionado e a lógica de cálculo do preço está na classe Café. Não apenas temos que adicionar uma nova classe de condimento a cada vez, o que cria possíveis alterações de código na classe principal, mas também lidar com a lógica de maneira diferente a cada vez.
Uma maneira melhor seria criar uma interface de condimentos que possa, por sua vez, ter classes filhas que sobrescrevam seus métodos. E a classe principal pode apenas usar a interface de condimentos para passar os parâmetros e obter a quantidade e o preço de cada pedido.
Isso tem duas vantagens:
1. Você pode alterar dinamicamente seu pedido para ter diferentes ou até vários condimentos (café com mocha e chocolate soa divino).
2. A classe Condiments terá um relacionamento com a classe Coffee, em vez de is-a. Portanto, seu café pode ter um tipo de café mocha/manteiga/leite em vez de seu café é um tipo de café mocha/manteiga/leite.
Cada subclasse ou classe derivada deve ser substituível por sua classe base ou pai.
Isso significa que a subclasse deve ser capaz de substituir diretamente a classe pai; deve ter a mesma funcionalidade. Achei difícil de entender porque soa como uma fórmula matemática complexa. Mas vou tentar deixar claro neste artigo.
Considere a equipe do café. Existem baristas, gerentes e servidores. Todos eles têm funcionalidade semelhante.
Portanto, podemos criar uma classe Staff base com name, position, getName, getPostion, takeOrder(), serve().
Cada uma das classes concretas, Garçom, Barista e Gerente pode derivar disso e substituir os mesmos métodos para implementá-los conforme necessário para o cargo.
Neste exemplo, o Princípio de Substituição de Liskov (LSP) é usado para garantir que qualquer classe derivada de Staff possa ser usada de forma intercambiável com a classe base de Staff sem afetar a correção do código.
Por exemplo, a classe Waiter estende a classe Staff e substitui os métodos takeOrder e serveOrder para incluir funcionalidade adicional específica para a função de um garçom. No entanto, mais importante, apesar das diferenças de funcionalidade, qualquer código que espera um objeto da classe Staff também pode funcionar corretamente com um objeto da classe Waiter.
public class Cafe { public void serveCustomer (Staff staff) { staff.takeOrder(); staff.serveOrder(); } } public class Main { public static void main (String[] args) { Cafe cafe = new Cafe(); Staff staff1 = new Staff( "John" , "Staff" ); Waiter waiter1 = new Waiter( "Jane" ); restaurant.serveCustomer(staff1); // Works correctly with Staff object
restaurant.serveCustomer(waiter1); // Works correctly with Waiter object
} }
Aqui o método serveCustomer() na classe Cafe, pega um objeto Staff como parâmetro. O método serveCustomer() chama os métodos takeOrder() e serveOrder() do objeto Staff para atender o cliente.
Na classe Main, criamos um objeto Staff e um objeto Waiter. Em seguida, chamamos o método serveCustomer() da classe Cafe duas vezes - uma vez com o objeto Staff e outra com o objeto Waiter.
Como a classe Waiter é derivada da classe Staff, qualquer código que espera um objeto da classe Staff também pode funcionar corretamente com um objeto da classe Waiter. Nesse caso, o método serveCustomer() da classe Cafe funciona corretamente com os objetos Staff e Waiter, mesmo que o objeto Waiter tenha funcionalidade adicional específica para a função de um garçom.
As classes não devem ser forçadas a depender de métodos que não usam.
Portanto, existe esta máquina de venda automática muito versátil no café que pode servir café, chá, lanches e refrigerantes.
O que está errado com isso? Nada tecnicamente. Se você tiver que implementar a interface para qualquer uma das funções, como servir café, também precisará implementar outros métodos para chá, refrigerante e lanches. Isso é desnecessário e essas funções não estão relacionadas entre si. Cada uma dessas funções tem muito menos coesão entre elas.
O que é coesão? É um fator que determina quão fortemente os métodos em uma interface se relacionam entre si.
E no caso da máquina de venda automática, os métodos dificilmente são interdependentes. Podemos segregar os métodos, pois eles têm coesão muito baixa.
Agora, qualquer interface que se destina a implementar uma coisa, deve implementar apenas takeMoney() que é comum para todas as funções. Isso separa as funções não relacionadas em uma interface, evitando assim a implementação forçada de funções não relacionadas em uma interface.
Módulos de alto nível não devem depender de módulos de baixo nível. Detalhes devem depender de abstrações.
Considere o ar condicionado (resfriador) no café. E se você for como eu, está sempre congelando lá dentro. Vejamos o controle remoto e as classes AC.
Aqui, remoteControl é o módulo de alto nível que depende de AC, o componente de baixo nível. Se eu conseguir um voto, também gostaria de um aquecedor :P Então, para ter uma regulação genérica de temperatura, em vez de um refrigerador, vamos desacoplar o controle remoto e o controle de temperatura. Mas a classe remoteControl está fortemente acoplada com AC, que é uma implementação concreta. Para desacoplar a dependência, podemos criar uma interface que tenha funções apenas de aumentarTemp() e diminuirTemp() dentro de um intervalo de, digamos, 45-65 F.
Como você pode ver, os módulos de alto e baixo nível dependem de uma interface que abstrai a funcionalidade de aumentar ou diminuir a temperatura.
A classe de concreto, AC, implementa os métodos com a faixa de temperatura aplicável.
Agora provavelmente posso obter o aquecedor que desejo, implementando diferentes faixas de temperatura em uma classe de concreto diferente chamada aquecedor.
O módulo de alto nível, remoteControl, só precisa se preocupar em chamar o método correto durante o tempo de execução.