Qualidade de software é a base fundamental para o desenvolvimento de qualquer sistema. Afinal, quanto maior a qualidade do software, menor a quantidade de erros, além de facilitar a manutenção e a expansão de novas funcionalidades no futuro.
De forma simples, pode-se dizer que a qualidade do software é inversamente proporcional à incidência de erros.
Com isso em mente, diversos estudos e abordagens foram desenvolvidos para aumentar a qualidade do software.
Dentre essas abordagens, surgiu o tão amado ou temido SOLID.
Mas o que é SOLID, afinal?
SOLID é um acrônimo que representa cinco princípios que facilitam o processo de desenvolvimento, deixando o código mais limpo, separando responsabilidades, diminuindo acoplamentos, facilitando a refatoração e estimulando o reaproveitamento de código, aumentando, desta forma, a qualidade do seu sistema.
Esses princípios podem ser aplicados em qualquer linguagem que adote a Programação Orientada a Objetos (POO).
Vamos começar do começo, então:
S - Single Responsibility Principle ou Princípio da Responsabilidade Única
De forma simples, esse princípio define que "Cada classe deve ter um, e somente um, motivo para mudar", sendo essa a essência do conceito.
Isso significa que uma classe ou módulo deve realizar uma única tarefa, tendo responsabilidade somente por ela.
Por exemplo, quando você começou a aprender programação orientada a objetos, provavelmente se deparou com classes nesse estilo:
struct Report;
impl Report{
fn load(){}
fn save(){}
fn update(){}
fn delete(){}
fn print_report(){}
fn show_report(){}
fn verify(){}
}
Nota: Isso é em Rust. Em Rust, não existe o conceito de classes; em vez disso, usamos estruturas como
struct
etraits
para atingir esse tipo de comportamento. No entanto, a implementação e explicação do SOLID também são possíveis nesta linguagem.
Podemos ver que, neste exemplo, temos a struct Report
(que em outras linguagens seria uma classe) e ela implementa diversos métodos. O problema não está na quantidade de métodos em si, mas no fato de que cada um deles faz coisas totalmente desconexas, o que faz com que a struct
tenha mais de uma responsabilidade no sistema.
Isso quebra o princípio do Single Responsibility Principle, fazendo com que uma classe tenha mais de uma tarefa e, desta forma, mais de uma responsabilidade dentro do sistema. Essas são chamadas de God Classes (Classes Deus) — classes ou structs que fazem de tudo. Num primeiro momento, isso pode parecer eficiente, mas, quando há necessidade de alterações nessa classe, será complicado modificar uma das responsabilidades sem comprometer as demais.
God Class — Classe Deus: Na programação orientada a objetos, é uma classe que sabe demais ou faz demais.
Quebrar esse princípio pode gerar uma falta de coesão, pois uma classe não pode assumir responsabilidades que não são suas, além de criar um alto acoplamento, devido ao aumento de dependências, e dificuldade de reaproveitar o código.
-
Falta de coesão:
- Uma classe não pode assumir responsabilidades que não são suas.
-
Alto acoplamento:
- Devido ao aumento de responsabilidades, gera-se um nível maior de dependências.
-
Dificuldade de reaproveitar o código:
- Código com muitas responsabilidades é mais difícil de reutilizar.
Podemos corrigir aquele código simplesmente aplicando a regra do Single Responsibility Principle:
struct Report;
impl Report {
fn verify(){}
fn get_report(){}
}
struct ReportRepository;
impl ReportRepository {
fn load(){}
fn save(){}
fn update(){}
fn delete(){}
}
struct ReportView;
impl ReportView {
fn print_report(){}
fn show_report(){}
}
Fazendo isso, você deixa cada struct/classe com uma única tarefa, ou seja, com apenas uma responsabilidade. Lembrando que esse princípio não se aplica somente a structs/classes, mas também a funções e métodos.
O - Open-Closed Principle ou Princípio Aberto-Fechado
Essa sigla é definida como "entidades de software (como classes e métodos) devem estar abertas para extensão, mas fechadas para modificação". Isso significa que você deve poder adicionar novas funcionalidades a uma classe sem alterar o código existente. Basicamente, quanto mais recursos adicionarmos a uma classe, mais complexa ela fica.
Para entender melhor, vamos pensar em uma struct
de Forma:
struct Shape {
t: String,
r: f64,
l: f64,
h: f64,
}
impl Shape {
fn area(&self) -> f64 {
if self.t == "circle" {
3.14 * self.r * self.r
} else {
0.0
}
}
}
Para adicionar a identificação de um retângulo, um programador menos experiente poderia sugerir alterar a estrutura if
, adicionando uma nova condição. No entanto, isso vai contra o Princípio Aberto-Fechado, pois alterar uma classe já existente e totalmente funcional pode introduzir novos bugs.
Então, o que deveríamos fazer para adicionar essa nova tarefa?
Basicamente, precisamos construir a função usando uma interface ou um trait
, isolando o comportamento extensível atrás dessa estrutura. No Rust, isso ficaria desta forma:
trait Shape {
fn area(&self) -> f64;
}
struct Circle {
radius: f64,
}
impl Shape for Circle {
fn area(&self) -> f64 {
3.14 * self.radius * self.radius
}
}
struct Rectangle {
width: f64,
height: f64,
}
impl Shape for Rectangle {
fn area(&self) -> f64 {
self.width * self.height
}
}
Com essa abordagem, podemos adicionar diversas outras formas sem alterar o código já existente.
Isso é o princípio do Open-Closed Principle em ação, deixando tudo mais limpo e simples de analisar, se necessário, no futuro.
A chave é que cada nova forma implementa a trait Shape
com seu próprio método area
, permitindo que o sistema seja estendido com novas formas sem modificar as implementações já existentes. Isso promove a extensibilidade do software e minimiza o risco de introduzir erros em código já testado e funcional.
Dessa forma, conseguimos manter o código organizado e reduzir o impacto de futuras mudanças, facilitando a manutenção e evolução do sistema
L - Liskov Substitution Principle ou Princípio da Substituição de Liskov
Esse princípio tem a seguinte definição: "Classes derivadas (ou classes-filhas) devem ser capazes de substituir suas classes-base (ou classes-mães)". Ou seja, uma classe filha deve ser capaz de executar todas as ações de sua classe pai.
Vamos direto para um exemplo prático para entendermos melhor:
struct Square {
side: f64,
}
impl Square {
fn set_height(&mut self, height: f64) {
self.side = height;
}
fn set_width(&mut self, width: f64) {
self.side = width;
}
fn area(&self) -> f64 {
self.side * self.side
}
}
fn main() {
let mut sq = Square { side: 5.0 };
sq.set_height(4.0);
println!("Area: {}", sq.area());
}
Nesse caso, o quadrado quebra essa regra diretamente, pois a trait
fala que tanto altura quanto largura podem ser diferentes, o que para um quadrado não é válido.
Neste caso, o que podemos fazer? Simples:
trait Rectangle {
fn set_height(&mut self, height: f64);
fn set_width(&mut self, width: f64);
fn area(&self) -> f64;
}
struct Square {
side: f64,
}
impl Square {
fn set_side(&mut self, side: f64) {
self.side = side;
}
}
impl Rectangle for Square {
fn set_height(&mut self, height: f64) {
self.set_side(height);
}
fn set_width(&mut self, width: f64) {
self.set_side(width);
}
fn area(&self) -> f64 {
self.side * self.side
}
}
fn main() {
let mut sq = Square { side: 5.0 };
sq.set_height(4.0);
println!("Area: {}", sq.area());
}
Agora, o quadrado adota o Princípio da Substituição de Liskov, pois ele respeita as regras do trait
do Retângulo. Este é apenas um dos exemplos de como essa regra se aplica.
Exemplos de violação do LSP:
- Sobrescrever/implementar um método que não faz nada.
- Lançar uma exceção inesperada.
- Retornar valores de tipos diferentes da classe base.
Para não violar esse princípio, é necessário, em muitos casos, utilizar injeção de dependência, além de outros princípios do próprio SOLID.
Com isso, dá para perceber que esses princípios se conectam e se completam conforme você vai entendendo como eles funcionam.
I - Interface Segregation Principle ou Princípio da Segregação de Interfaces
De forma simples, o Princípio da Segregação de Interfaces afirma que uma classe não deve ser forçada a implementar interfaces e métodos que não irá utilizar. Ou seja, não devemos criar uma única interface genérica.
Vamos para um exemplo prático para entender melhor:
trait Worker {
fn work(&self);
fn eat(&self);
}
struct Human;
impl Worker for Human {
fn work(&self) {}
fn eat(&self) {}
}
struct Robot;
impl Worker for Robot {
fn work(&self) {}
fn eat(&self) {} // robos comem????
}
Nesse exemplo, temos uma interface (trait) Worker
que exige que as classes que a implementam possuam dois métodos: work
e eat
. Faz sentido que a classe Human
implemente esses dois métodos, já que humanos trabalham e comem, mas e o Robot
? Um robô trabalha, mas ele não come. Portanto, ele é forçado a implementar o método eat
, mesmo sem necessidade.
Como podemos arrumar isso? A resposta é criar interfaces específicas.
trait Workable {
fn work(&self);
}
trait Eatable {
fn eat(&self);
}
struct Human;
impl Workable for Human {
fn work(&self) {}
}
impl Eatable for Human {
fn eat(&self) {}
}
struct Robot;
impl Workable for Robot {
fn work(&self) {}
}
Pronto, problema resolvido! Agora temos duas interfaces distintas: Workable
e Eatable
. Cada uma representa uma responsabilidade específica. A classe Human
implementa ambas as interfaces, enquanto a classe Robot
implementa apenas a interface Workable
.
Ao adotar interfaces específicas, evitamos forçar classes a implementar métodos desnecessários, mantendo o código mais limpo, coeso e fácil de manter. Este é o cerne do Princípio da Segregação de Interfaces, que ajuda a criar sistemas mais flexíveis e robustos.
D - Dependency Inversion Principle ou Princípio da Inversão de Dependência
Este princípio tem duas regras explícitas:
- Módulos de alto nível não devem depender de módulos de baixo nível. Ambos devem depender da abstração.
- Abstrações não devem depender de detalhes. Detalhes devem depender de abstrações.
E o que isso quer dizer? Simples: dependa de abstrações e não de implementações.
Vamos para um exemplo prático para entender melhor:
struct Light;
impl Light {
fn turn_on(&self) {
println!("Light is on");
}
fn turn_off(&self) {
println!("Light is off");
}
}
struct Switch {
light: Light,
}
impl Switch {
fn new(light: Light) -> Switch {
Switch { light }
}
fn operate(&self, on: bool) {
if on {
self.light.turn_on();
} else {
self.light.turn_off();
}
}
}
fn main() {
let light = Light;
let switch = Switch::new(light);
switch.operate(true);
switch.operate(false);
}
Podemos ver que a classe Switch
depende totalmente da classe Light
. Nesse caso, se precisarmos substituir a classe Light
por uma classe Fan
, teríamos que modificar a classe Switch
também.
Como resolver isso? Dependa de abstrações e não de implementações, conforme o princípio:
trait Switchable {
fn turn_on(&self);
fn turn_off(&self);
}
struct Light;
impl Switchable for Light {
fn turn_on(&self) {
println!("Light is on");
}
fn turn_off(&self) {
println!("Light is off");
}
}
struct Fan;
impl Switchable for Fan {
fn turn_on(&self) {
println!("Fan is on");
}
fn turn_off(&self) {
println!("Fan is off");
}
}
struct Switch<'a> {
device: &'a dyn Switchable,
}
impl<'a> Switch<'a> {
fn new(device: &'a dyn Switchable) -> Switch<'a> {
Switch { device }
}
fn operate(&self, on: bool) {
if on {
self.device.turn_on();
} else {
self.device.turn_off();
}
}
}
fn main() {
let light = Light;
let switch_for_light = Switch::new(&light);
switch_for_light.operate(true);
switch_for_light.operate(false);
let fan = Fan;
let switch_for_fan = Switch::new(&fan);
switch_for_fan.operate(true);
switch_for_fan.operate(false);
}
Agora, Switch
depende da abstração Switchable
, e tanto Light
quanto Fan
implementam essa abstração. Isso permite que Switch
funcione com qualquer dispositivo que implemente a interface Switchable
.
Benefícios:
-
Flexibilidade: Adicionar novos dispositivos que implementam
Switchable
é fácil, sem precisar alterar o código existente deSwitch
. - Manutenção: Alterações no comportamento específico dos dispositivos não afetam a lógica de controle.
- Desacoplamento: Reduz o acoplamento entre módulos de alto e baixo nível, facilitando testes e desenvolvimento paralelo.
Conclusão
Utilizando os princípios SOLID, você pode tornar seu software mais robusto, escalável e flexível, facilitando a modificação sem muita dor de cabeça e dificuldade.
SOLID é essencial para desenvolvedores, e geralmente é usado em conjunto com as práticas de Clean Code para aumentar ainda mais a qualidade do seu sistema.
Embora possa parecer extremamente difícil entender esses conceitos e exemplos no início, é importante lembrar que nem sempre conseguiremos aplicar todos esses princípios durante o desenvolvimento. No entanto, com prática e persistência, você conseguirá escrever códigos cada vez mais maduros e robustos. SOLID será seu guia nessa jornada.
Se quiser entender um pouco mais e com ilustrações, recomendo este vídeo do Filipe Deschamps:
Se você leu até aqui, recomendo fortemente que comece a implementar esses princípios em seus projetos, pois para aprender a programar, não há nada melhor que: PROGRAMAR.
Obrigado por ler!
Top comments (1)
Muito boa a explicação, parabéns!