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 {
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() {
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.- 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.
- 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();
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) {
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?
