Princípios do SOLID que todo desenvolvedor deve conhecer

A programação orientada a objetos trouxe novas abordagens para o design de aplicativos no desenvolvimento de software. Em particular, o OOP permitiu que os programadores combinassem entidades, unidas por um objetivo ou funcionalidade comum, em classes separadas, projetadas para resolver problemas independentes e independentes de outras partes do aplicativo. No entanto, o uso do OOP sozinho não significa que o desenvolvedor esteja seguro da possibilidade de criar código obscuro e confuso, difícil de manter. Robert Martin, a fim de ajudar todos que desejam desenvolver aplicativos OOP de alta qualidade, desenvolveu cinco princípios de programação e design orientados a objetos, falando sobre os quais, com a ajuda de Michael Fazers, eles usam o acrônimo SOLID.



O material, cuja tradução publicamos hoje, é dedicado aos conceitos básicos do SOLID e destina-se a desenvolvedores iniciantes.

O que é o SOLID?


Veja como o acrônimo SOLID significa:

  • S: Princípio da responsabilidade única.
  • O: Princípio Aberto-Fechado.
  • L: Princípio de substituição de Liskov (Princípio de substituição de Barbara Liskov).
  • I: Princípio de Segregação de Interface.
  • D: Princípio da inversão de dependência.

Agora vamos considerar esses princípios em exemplos esquemáticos. Observe que o principal objetivo dos exemplos é ajudar o leitor a entender os princípios do SOLID, aprender como aplicá-los e como segui-los ao projetar aplicativos. O autor do material não se esforçou para alcançar um código de trabalho que pudesse ser usado em projetos reais.

Princípio da responsabilidade exclusiva


“Uma tarefa. Só uma coisa. - Loki diz a Skurge no filme Thor: Ragnarok.
Cada classe deve resolver apenas um problema.

Uma classe só deve ser responsável por uma coisa. Se uma classe é responsável por resolver vários problemas, seus subsistemas que implementam a solução desses problemas acabam se relacionando. Alterações em um desses subsistemas levam a alterações em outro.

Observe que esse princípio se aplica não apenas às classes, mas também aos componentes de software em um sentido mais amplo.

Por exemplo, considere este código:

class Animal {    constructor(name: string){ }    getAnimalName() { }    saveAnimal(a: Animal) { } } 

A classe Animal apresentada aqui descreve algum tipo de animal. Esta classe viola o princípio de responsabilidade exclusiva. Como exatamente esse princípio é violado?

De acordo com o princípio da responsabilidade exclusiva, uma classe deve resolver apenas uma tarefa. Ele resolve os dois trabalhando com o armazém de dados no método saveAnimal e manipulando as propriedades do objeto no construtor e no método getAnimalName .

Como essa estrutura de classes pode levar a problemas?

Se o procedimento para trabalhar com o armazém de dados usado pelo aplicativo for alterado, será necessário fazer alterações em todas as classes que trabalham com o armazém. Essa arquitetura não é flexível, as alterações em alguns subsistemas afetam outras, que se assemelham ao efeito dominó.

Para alinhar o código acima com o princípio de responsabilidade exclusiva, criaremos outra classe cuja única tarefa é trabalhar com o repositório, em particular, armazenar objetos da classe Animal nele:

 class Animal {   constructor(name: string){ }   getAnimalName() { } } class AnimalDB {   getAnimal(a: Animal) { }   saveAnimal(a: Animal) { } } 

Eis o que Steve Fenton diz sobre isso: “Ao projetar classes, devemos nos esforçar para integrar componentes relacionados, ou seja, aqueles nos quais as mudanças ocorrem pelos mesmos motivos. Devemos tentar separar os componentes, mudanças nas quais causam várias razões ".

A aplicação correta do princípio de responsabilidade exclusiva leva a um alto grau de conectividade dos elementos dentro do módulo, ou seja, ao fato de que as tarefas resolvidas nele correspondem bem ao seu objetivo principal.

Princípio aberto-fechado


As entidades de software (classes, módulos, funções) devem estar abertas para expansão, mas não para modificação.

Continuamos a trabalhar na classe Animal .

 class Animal {   constructor(name: string){ }   getAnimalName() { } } 

Queremos classificar a lista de animais, cada um dos quais é representado por um objeto da classe Animal , e descobrir quais sons eles produzem. Imagine que resolvemos esse problema usando a função AnimalSounds :

 //... const animals: Array<Animal> = [   new Animal('lion'),   new Animal('mouse') ]; function AnimalSound(a: Array<Animal>) {   for(int i = 0; i <= a.length; i++) {       if(a[i].name == 'lion')           return 'roar';       if(a[i].name == 'mouse')           return 'squeak';   } } AnimalSound(animals); 

O principal problema dessa arquitetura é que a função determina que tipo de som um animal faz ao analisar objetos específicos. A função AnimalSound está de acordo com o princípio de abertura-abertura, pois, por exemplo, quando novos tipos de animais aparecem, precisamos alterá-la para usá-la para reconhecer os sons emitidos por eles.

Adicione um novo elemento à matriz:

 //... const animals: Array<Animal> = [   new Animal('lion'),   new Animal('mouse'),   new Animal('snake') ] //... 

Depois disso, temos que alterar o código da função AnimalSound :

 //... function AnimalSound(a: Array<Animal>) {   for(int i = 0; i <= a.length; i++) {       if(a[i].name == 'lion')           return 'roar';       if(a[i].name == 'mouse')           return 'squeak';       if(a[i].name == 'snake')           return 'hiss';   } } AnimalSound(animals); 

Como você pode ver, ao adicionar um novo animal à matriz, você precisará complementar o código da função. Um exemplo é muito simples, mas se uma arquitetura semelhante for usada em um projeto real, a função precisará ser constantemente expandida, adicionando novas expressões if a ela.

Como alinhar a função AnimalSound com o princípio de aberto-fechado? Por exemplo, assim:

 class Animal {       makeSound();       //... } class Lion extends Animal {   makeSound() {       return 'roar';   } } class Squirrel extends Animal {   makeSound() {       return 'squeak';   } } class Snake extends Animal {   makeSound() {       return 'hiss';   } } //... function AnimalSound(a: Array<Animal>) {   for(int i = 0; i <= a.length; i++) {       a[i].makeSound();   } } AnimalSound(animals); 

Você pode perceber que a classe Animal agora tem um método makeSound virtual. Com essa abordagem, é necessário que as classes projetadas para descrever animais específicos expandam a classe Animal e implementem esse método.

Como resultado, cada classe que descreve um animal terá seu próprio método makeSound e, ao iterar sobre uma matriz com animais na função AnimalSound , será suficiente chamar esse método para cada elemento da matriz.

Se você agora adicionar um objeto que descreve o novo animal à matriz, não precisará alterar a função AnimalSound . Nós o alinhamos com o princípio de abertura-proximidade.

Considere outro exemplo.

Suponha que tenhamos uma loja. Damos aos clientes um desconto de 20% usando esta classe:

 class Discount {   giveDiscount() {       return this.price * 0.2   } } 

Agora foi decidido dividir os clientes em dois grupos. Os clientes favoritos ( fav ) recebem 20% de desconto e os clientes VIP ( vip ) - dobram o desconto, ou seja - 40%. Para implementar essa lógica, decidiu-se modificar a classe da seguinte maneira:

 class Discount {   giveDiscount() {       if(this.customer == 'fav') {           return this.price * 0.2;       }       if(this.customer == 'vip') {           return this.price * 0.4;       }   } } 

Essa abordagem viola o princípio de abertura-proximidade. Como você pode ver, aqui, se precisarmos dar um desconto especial a um determinado grupo de clientes, precisamos adicionar um novo código à classe.

Para processar esse código de acordo com o princípio de abertura-proximidade, adicionamos uma nova classe ao projeto que estende a classe Discount . Nesta nova classe, estamos implementando um novo mecanismo:

 class VIPDiscount: Discount {   getDiscount() {       return super.getDiscount() * 2;   } } 

Se você decidir conceder um desconto de 80% aos clientes “super VIP”, deve ficar assim:

 class SuperVIPDiscount: VIPDiscount {   getDiscount() {       return super.getDiscount() * 2;   } } 

Como você pode ver, o empoderamento das classes é usado aqui, não sua modificação.

O princípio da substituição de Barbara Liskov


É necessário que as subclasses sirvam como um substituto para suas superclasses.

O objetivo desse princípio é que as classes de herança possam ser usadas em vez das classes pai das quais elas são formadas sem interromper o programa. Se o tipo de classe for verificado no código, o princípio da substituição será violado.

Considere a aplicação desse princípio, retornando ao exemplo com a classe Animal . Escreveremos uma função projetada para retornar informações sobre o número de membros de um animal.

 //... function AnimalLegCount(a: Array<Animal>) {   for(int i = 0; i <= a.length; i++) {       if(typeof a[i] == Lion)           return LionLegCount(a[i]);       if(typeof a[i] == Mouse)           return MouseLegCount(a[i]);       if(typeof a[i] == Snake)           return SnakeLegCount(a[i]);   } } AnimalLegCount(animals); 

A função viola o princípio de substituição (e o princípio de abertura-fechamento). Esse código deve conhecer os tipos de todos os objetos processados ​​por ele e, dependendo do tipo, usar a função correspondente para calcular os membros de um animal em particular. Como resultado, ao criar um novo tipo de animal, a função precisará ser reescrita:

 //... class Pigeon extends Animal {      } const animals[]: Array<Animal> = [   //...,   new Pigeon(); ] function AnimalLegCount(a: Array<Animal>) {   for(int i = 0; i <= a.length; i++) {       if(typeof a[i] == Lion)           return LionLegCount(a[i]);       if(typeof a[i] == Mouse)           return MouseLegCount(a[i]);        if(typeof a[i] == Snake)           return SnakeLegCount(a[i]);       if(typeof a[i] == Pigeon)           return PigeonLegCount(a[i]);   } } AnimalLegCount(animals); 

Para que essa função não viole o princípio da substituição, nós a transformamos usando os requisitos formulados por Steve Fenton. Eles consistem no fato de que métodos que aceitam ou retornam valores com o tipo de uma determinada superclasse ( Animal no nosso caso) também devem aceitar e retornar valores cujos tipos são suas subclasses ( Pigeon ).

Armado com essas considerações, podemos refazer a função AnimalLegCount :

 function AnimalLegCount(a: Array<Animal>) {   for(let i = 0; i <= a.length; i++) {       a[i].LegCount();   } } AnimalLegCount(animals); 

Agora, esta função não está interessada nos tipos de objetos passados ​​para ela. Ela simplesmente chama seus métodos LegCount . Tudo o que ela sabe sobre tipos é que os objetos que processa devem pertencer à classe Animal ou a suas subclasses.

O método LegCount agora deve aparecer na classe Animal :

 class Animal {   //...   LegCount(); } 

E suas subclasses precisam implementar este método:

 //... class Lion extends Animal{   //...   LegCount() {       //...   } } //... 

Como resultado, por exemplo, ao acessar o método LegCount para uma instância da classe Lion , o método implementado nesta classe é chamado e é retornado exatamente o que é esperado de chamar esse método.

Agora, a função AnimalLegCount não precisa saber sobre qual objeto de uma subclasse específica da classe Animal processa para descobrir informações sobre o número de membros no animal representado por esse objeto. A função simplesmente chama o método LegCount da classe Animal , pois as subclasses dessa classe devem implementar esse método para que possam ser usadas em vez disso, sem violar a operação correta do programa.

Princípio de separação de interface


Crie interfaces altamente especializadas projetadas para um cliente específico. Os clientes não devem depender de interfaces que eles não usam.

Este princípio visa solucionar as deficiências associadas à implementação de grandes interfaces.

Considere a interface Shape :

 interface Shape {   drawCircle();   drawSquare();   drawRectangle(); } 

Ele descreve métodos para desenhar círculos ( drawCircle ), quadrados ( drawSquare ) e retângulos ( drawRectangle ). Como resultado, as classes que implementam essa interface e representam formas geométricas individuais, como um círculo, um quadrado e um retângulo, devem conter uma implementação de todos esses métodos. É assim:

 class Circle implements Shape {   drawCircle(){       //...   }   drawSquare(){       //...   }   drawRectangle(){       //...   } } class Square implements Shape {   drawCircle(){       //...   }   drawSquare(){       //...   }   drawRectangle(){       //...   } } class Rectangle implements Shape {   drawCircle(){       //...   }   drawSquare(){       //...   }   drawRectangle(){       //...   } } 

Código estranho acabou. Por exemplo, a classe Rectangle representa um retângulo implementa métodos ( drawCircle e drawSquare ) que ele não precisa. O mesmo pode ser visto ao analisar o código de duas outras classes.

Suponha que decidimos adicionar outro método à interface Shape , drawTriangle , projetada para desenhar triângulos:

 interface Shape {   drawCircle();   drawSquare();   drawRectangle();   drawTriangle(); } 

Isso resultará em classes que representam formas geométricas específicas, além de implementar o método drawTriangle . Caso contrário, ocorrerá um erro.

Como você pode ver, com essa abordagem, é impossível criar uma classe que implemente um método para gerar um círculo, mas não implemente métodos para derivar um quadrado, retângulo e triângulo. Esses métodos podem ser implementados para que, quando eles sejam gerados, seja gerado um erro indicando que tal operação não possa ser executada.

O princípio da separação de interface nos adverte contra a criação de interfaces como Shape partir do nosso exemplo. Clientes (temos as classes Circle , Square e Rectangle ) não devem implementar métodos que eles não precisam usar. Além disso, esse princípio indica que a interface deve resolver apenas uma tarefa (pois é semelhante ao princípio de responsabilidade exclusiva); portanto, tudo o que excede o escopo dessa tarefa deve ser transferido para outra interface ou interfaces.

No nosso caso, a interface Shape resolve problemas cuja solução é necessária para criar interfaces separadas. Seguindo essa idéia, reformulamos o código criando interfaces separadas para resolver várias tarefas altamente especializadas:

 interface Shape {   draw(); } interface ICircle {   drawCircle(); } interface ISquare {   drawSquare(); } interface IRectangle {   drawRectangle(); } interface ITriangle {   drawTriangle(); } class Circle implements ICircle {   drawCircle() {       //...   } } class Square implements ISquare {   drawSquare() {       //...   } } class Rectangle implements IRectangle {   drawRectangle() {       //...   } } class Triangle implements ITriangle {   drawTriangle() {       //...   } } class CustomShape implements Shape {  draw(){     //...  } } 

Agora, a interface ICircle usada apenas para desenhar círculos, bem como outras interfaces especializadas para desenhar outras formas. A interface Shape pode ser usada como interface universal.

Princípio de Inversão de Dependência


O objeto da dependência deve ser uma abstração, não algo específico.

  1. Os módulos de nível superior não devem depender dos módulos de nível inferior. Ambos os tipos de módulos devem depender de abstrações.
  2. As abstrações não devem depender dos detalhes. Os detalhes devem depender de abstrações.

No processo de desenvolvimento de software, há um momento em que a funcionalidade do aplicativo deixa de caber no mesmo módulo. Quando isso acontece, temos que resolver o problema das dependências do módulo. Como resultado, por exemplo, pode acontecer que os componentes de alto nível dependam dos componentes de baixo nível.

 class XMLHttpService extends XMLHttpRequestService {} class Http {   constructor(private xmlhttpService: XMLHttpService) { }   get(url: string , options: any) {       this.xmlhttpService.request(url,'GET');   }   post() {       this.xmlhttpService.request(url,'POST');   }   //... } 

Aqui, a classe Http é um componente de alto nível e o XMLHttpService é um componente de baixo nível. Essa arquitetura viola a cláusula A do princípio da inversão de dependência: “Módulos de níveis mais altos não devem depender de módulos de níveis mais baixos. Ambos os tipos de módulos devem depender de abstrações. ”

A classe Http é forçada a depender da classe XMLHttpService . Se decidirmos alterar o mecanismo usado pela classe Http para interagir com a rede, digamos que será um serviço Node.js. ou, por exemplo, um serviço stub usado para fins de teste, teremos que editar todas as instâncias da classe Http alterando o código correspondente. Isso viola o princípio de abertura-proximidade.

A classe Http não deve saber exatamente o que é usado para estabelecer uma conexão de rede. Portanto, criaremos a interface de Connection :

 interface Connection {   request(url: string, opts:any); } 

A interface de Connection contém uma descrição do método de request e passamos o argumento do tipo de Connection para a classe Http :

 class Http {   constructor(private httpConnection: Connection) { }   get(url: string , options: any) {       this.httpConnection.request(url,'GET');   }   post() {       this.httpConnection.request(url,'POST');   }   //... } 

Agora, independentemente do que é usado para organizar a interação com a rede, a classe Http pode usar o que foi passado para ela, sem se preocupar com o que está oculto por trás da interface do Connection .

XMLHttpService classe XMLHttpService para que ela implemente esta interface:

 class XMLHttpService implements Connection {   const xhr = new XMLHttpRequest();   //...   request(url: string, opts:any) {       xhr.open();       xhr.send();   } } 

Como resultado, podemos criar muitas classes que implementam a interface Connection e são adequadas para uso na classe Http para organizar a troca de dados pela rede:

 class NodeHttpService implements Connection {   request(url: string, opts:any) {       //...   } } class MockHttpService implements Connection {   request(url: string, opts:any) {       //...   } } 

Como você pode ver, aqui os módulos de alto e baixo nível dependem de abstrações. A classe Http (módulo de alto nível) depende da interface de Connection (abstração). As XMLHttpService , NodeHttpService e MockHttpService (módulos de baixo nível) também dependem da interface de Connection .

Além disso, vale ressaltar que, seguindo o princípio da inversão da dependência, observamos o princípio da substituição Barbara Liskov. Ou seja, os tipos XMLHttpService , NodeHttpService e MockHttpService podem servir como um substituto para o tipo básico Connection .

Sumário


Aqui, analisamos cinco princípios do SOLID que todos os desenvolvedores de OOP devem aderir. No início, isso pode não ser fácil, mas se você se esforçar para isso, reforçando os desejos da prática, esses princípios se tornarão uma parte natural do fluxo de trabalho, que terá um enorme impacto positivo na qualidade dos aplicativos e facilitará muito o suporte deles.

Caros leitores! Você usa os princípios do SOLID em seus projetos?

Source: https://habr.com/ru/post/pt426413/


All Articles