Como aplicar os princípios SOLID em Python passo a passo

  • Os princípios SOLID fornecem uma base clara para projetar código Python orientado a objetos mais legível, fácil de manter e escalável.
  • Cada princípio (SRP, OCP, LSP, ISP e DIP) aborda um tipo específico de problema de projeto, desde responsabilidades mal separadas até dependências rígidas.
  • Aplicar os princípios SOLID com classes, abstrações e injeção de dependência em Python reduz o acoplamento, melhora a testabilidade e facilita a evolução do sistema.

Sólido em Python

Ao começar a trabalhar em grandes projetos Python, uma das primeiras coisas que você percebe é que O código torna-se difícil de entender, testar e estender. Se você não seguir algumas regras básicas de design, é aí que entram os famosos princípios SOLID: um conjunto de boas práticas criado para facilitar muito a vida da equipe.

Esses princípios tiveram origem no campo de Programação orientada a objetos clássica (Java, C++, C#, etc.)Mas elas se encaixam perfeitamente em Python, desde que você use classes e objetos de uma forma mais ou menos séria. Vamos analisar em detalhes o que são, de onde vêm, por que são importantes e, acima de tudo, como usá-las. Aplique os princípios SOLID com exemplos claros em Python. Para tornar seu código mais fácil de manter, escalável e agradável de usar.

O que é SOLID e qual a sua origem?

O termo SOLID é uma sigla popularizada por Michael Feathers. Agrupar cinco princípios de design originalmente propostos por Robert C. Martin, mais conhecido como Tio Bob. Este engenheiro de software americano, um dos signatários do Manifesto Ágil, publicou o artigo "Os Princípios da OOD" em meados da década de 90 e, posteriormente, "Princípios de Design e Padrões de Design", onde lançou muitas das bases do design orientado a objetos moderno.

Com o tempo, outros autores como Barbara Liskov e Bertrand Meyer Eles também contribuíram com ideias que foram integradas a esse conjunto de princípios. Michael Feathers simplesmente teve a ideia (muito astuta) de reorganizá-los para que as iniciais formassem a palavra SOLID, o que ajudou a disseminá-los rapidamente na comunidade de desenvolvimento.

As cinco letras de SOLID correspondem a estes princípios de design orientado a objetos, também aplicáveis ​​ao Python:

  • S – Princípio da Responsabilidade Única (Princípio da Responsabilidade Única)
  • O – Princípio Aberto/Fechado (Princípio Aberto/Fechado)
  • L – Princípio da Substituição de Liskov (Princípio da Substituição de Liskov)
  • I – Princípio de Segregação de Interface (Princípio da Segregação de Interface)
  • D – Princípio da Inversão de Dependência (Princípio da Inversão de Dependência)

A ideia geral é que esses cinco princípios, usados ​​em conjunto, Eles ajudam você a escrever software flexível, fácil de testar e de manter.Isso se traduz em implantações mais rápidas, menos bugs misteriosos, melhor reutilização de código e menos dores de cabeça quando o projeto já está em produção há alguns anos.

Para que servem os princípios SOLID em Python?

Aplicar os princípios SOLID em Python não é apenas um exercício acadêmico; tem um impacto direto no trabalho diário da equipe. Ao aderir a esses princípios, Elas reduzem a complexidade do código, diminuem o "cheiro ruim" do código e evitam que sua base de código "cheire a podre".Usando a famosa analogia "se cheira mal, é porque foi mal projetado", muitos desenvolvedores do Windows optam por... Instalar e configurar o WSL2 Ter um ambiente Linux mais próximo da produção.

Em ambientes colaborativos (equipes de desenvolvimento backend, engenharia de dados, produtos com ciclos longos, etc.), esses princípios são fundamentais para Várias pessoas podem trabalhar na mesma base de código sem ultrapassar os limites ou quebrar tudo ao menor toque.Além disso, o Python, embora flexível e dinâmico, permite a aplicação perfeita de abstrações típicas da Programação Orientada a Objetos (POO): classes abstratas, hierarquias de herança, composição e interfaces. abc, etc.

Em resumo, o SOLID ajuda você a alcançar:

  • Código mais limpo e legívelmesmo anos depois de tê-lo escrito.
  • Testabilidade aprimoradaPorque as responsabilidades estão claramente separadas.
  • Alta capacidade de reutilização e escalabilidade. Graças a menos dependências rígidas entre os módulos.
  • Menos erros colateraisAo alterar algo em um módulo, você não quebra acidentalmente outras cinco coisas.

S – Princípio da Responsabilidade Única

O primeiro princípio afirma que Uma turma deve ter apenas um motivo para mudar.Em outras palavras, deve assumir uma única responsabilidade bem definida. Isso não significa ter apenas um método, mas sim que toda a sua lógica deve apontar para um propósito único e coerente.

Imagine uma classe em Python que representa um usuário e, além de armazenar seus dados, também lida com o acesso ao banco de dados e a geração de relatórios:

class User:
    def __init__(self, name: str):
        self.name = name

    def get_user_from_database(self, user_id: int) -> dict:
        # Recupera datos desde la base de datos
        # ...
        pass

    def save_user_to_database(self) -> None:
        # Persiste el usuario en la base de datos
        # ...
        pass

    def generate_user_report(self) -> str:
        # Genera un informe del usuario
        # ...
        pass

Aqui está a aula combina três responsabilidades distintasRepresentação do usuário, gerenciamento da persistência e geração de relatórios. Alterações no banco de dados, no formato do relatório ou nos atributos do usuário exigem a modificação da mesma classe, aumentando o risco de introduzir bugs transversais.

Se separarmos essas preocupações, o projeto melhora significativamente:

class User:
    def __init__(self, name: str):
        self.name = name


class UserDB:
    @staticmethod
    def get_user(user_id: int) -> User:
        # Lógica para obtener usuarios de la base de datos
        # ...
        return User("John Doe")

    @staticmethod
    def save_user(user: User) -> None:
        # Lógica para guardar el usuario
        # ...
        pass


class UserReportGenerator:
    @staticmethod
    def generate_report(user: User) -> str:
        # Lógica para generar informes de usuario
        # ...
        return f"Report for user: {user.name}"

Agora a turma O usuário representa apenas o usuário como uma entidade.Se a forma como os relatórios são gerados mudar, basta tocar em UserReportGeneratorSe você alterar o banco de dados, basta tocar em UserDBCada classe possui um único motivo para alteração, o que simplifica a depuração e a evolução do sistema.

SRP aplicado a um exemplo mais realista: patos e comunicação.

Vamos analisar um cenário clássico adaptado: uma turma Pato À qual, inicialmente, vão-se adicionando responsabilidades gradualmente até que se torne um monstro difícil de manter. Imagine uma implementação ingênua:

class Duck:
    def __init__(self, name: str):
        self.name = name

    def fly(self) -> None:
        print(f"{self.name} is flying not very high")

    def swim(self) -> None:
        print(f"{self.name} swims in the lake and quacks")

    def do_sound(self) -> str:
        return "Quack"

    def greet(self, other_duck: "Duck") -> None:
        print(f"{self.name}: {self.do_sound()}, hello {other_duck.name}")

A aula Deveria ser definido simplesmente como “um pato”.Mas também gerencia a forma como eles se comunicam entre si. Se amanhã você mudar a lógica da conversa (mais frases, outros idiomas, canais diferentes), você terá que modificar a classe do pato, que já funciona bem como uma entidade.

A solução que respeita o Princípio da Responsabilidade Única (SRP) é extrair essa segunda responsabilidade de outra classe especializada em comunicação:

class Duck:
    def __init__(self, name: str):
        self.name = name

    def fly(self) -> None:
        print(f"{self.name} is flying not very high")

    def swim(self) -> None:
        print(f"{self.name} swims in the lake and quacks")

    def do_sound(self) -> str:
        return "Quack"


class Communicator:
    def __init__(self, channel: str):
        self.channel = channel

    def communicate(self, duck1: Duck, duck2: Duck) -> None:
        sentence1 = f"{duck1.name}: {duck1.do_sound()}, hello {duck2.name}"
        sentence2 = f"{duck2.name}: {duck2.do_sound()}, hello {duck1.name}"
        conversation = 
        print(*conversation, f"(via {self.channel})", sep="\n")

Graças a essa separação, Você pode desenvolver a lógica de comunicação sem alterar a definição do pato.Além disso, o código é mais fácil de testar: você testa o comportamento de Duck e, por outro lado, aquele de Communicatorsem misturar responsabilidades.

O – Princípio Aberto/Fechado

O princípio OCP afirma que As entidades de software devem estar abertas à extensão de seu comportamento, mas fechadas a modificações diretas.Em outras palavras, quando você quiser adicionar uma nova funcionalidade, o ideal é não ter que reescrever classes que já funcionam e são usadas por outros módulos.

Um exemplo clássico é o cálculo das áreas de figuras geométricas. Vejamos primeiro uma versão que... Não respeita o princípio OCP.:

class Rectangle:
    def __init__(self, width: float, height: float):
        self.width = width
        self.height = height


class Circle:
    def __init__(self, radius: float):
        self.radius = radius


class AreaCalculator:
    def calculate_area(self, shape) -> float:
        if isinstance(shape, Rectangle):
            return shape.width * shape.height
        elif isinstance(shape, Circle):
            return 3.14159 * shape.radius * shape.radius
        else:
            raise ValueError("Forma no soportada")

Se você quiser adicionar um triângulo amanhã, será obrigado a modificar o código de AreaCalculatoradicionando outro elifIsso viola o Princípio Aberto/Fechado (OCP), porque a classe não está mais "fechada" para alterações.

A versão correta envolve a introdução de uma abstração. Shape com um método area() que cada figura implementa à sua maneira:

from abc import ABC, abstractmethod


class Shape(ABC):
    @abstractmethod
    def area(self) -> float:
        pass


class Rectangle(Shape):
    def __init__(self, width: float, height: float):
        self.width = width
        self.height = height

    def area(self) -> float:
        return self.width * self.height


class Circle(Shape):
    def __init__(self, radius: float):
        self.radius = radius

    def area(self) -> float:
        return 3.14159 * self.radius * self.radius


class AreaCalculator:
    def calculate_area(self, shape: Shape) -> float:
        return shape.area()

Graças a este design, para Adicione um triângulo que você não toca. AreaCalculatorBasta criar uma nova subclasse:

class Triangle(Shape):
    def __init__(self, base: float, height: float):
        self.base = base
        self.height = height

    def area(self) -> float:
        return 0.5 * self.base * self.height

O princípio Aberto/Fechado se encaixa muito bem com a ideia de Definir pontos de extensão claros por meio de abstraçõesInterfaces, classes abstratas, hooks, etc. Em Python, o módulo abc Isso permite que você expresse isso explicitamente, mesmo que a linguagem seja dinâmica.

Exemplo de aplicação do Princípio Aberto/Fechado (OCP) ao comunicador

Se voltarmos ao exemplo de ComunicadorPodemos ir um passo além e preparar o design para suportar diferentes tipos de conversas sem precisar reescrever o comunicador a cada vez. Para isso, definimos uma abstração de conversa e fazemos com que o comunicador a utilize apenas:

from typing import final
from abc import ABC, abstractmethod


class AbstractConversation(ABC):
    @abstractmethod
    def do_conversation(self) -> list:
        pass


class SimpleConversation(AbstractConversation):
    def __init__(self, duck1: Duck, duck2: Duck):
        self.duck1 = duck1
        self.duck2 = duck2

    def do_conversation(self) -> list:
        sentence1 = f"{self.duck1.name}: {self.duck1.do_sound()}, hello {self.duck2.name}"
        sentence2 = f"{self.duck2.name}: {self.duck2.do_sound()}, hello {self.duck1.name}"
        return 


class Communicator:
    def __init__(self, channel: str):
        self.channel = channel

    @final
    def communicate(self, conversation: AbstractConversation) -> None:
        print(*conversation.do_conversation(), f"(via {self.channel})", sep="\n")

Nesta versão, Se você quiser adicionar uma nova forma de conversar (por exemplo, uma conversa agressiva, uma conversa com alternância de turnos, etc.), você simplesmente cria outra subclasse de AbstractConversation. O método communicate() de Communicator Não houve alterações, estando em total conformidade com o OCP.

L – Princípio da Substituição de Liskov

O Princípio da Substituição de Liskov, formulado por Barbara Liskov, afirma que As subclasses devem ser capazes de substituir suas classes base sem alterar o comportamento esperado do programa.Na prática, isso significa que, se o código funcionar com uma instância da classe base, ele deverá funcionar igualmente bem com qualquer instância de uma subclasse.

Um exemplo típico de violação do Princípio da Substituição de Limites (LSP) é modelar todas as aves com um único método. fly()incluindo avestruzes:

class Bird:
    def fly(self) -> None:
        pass


class Duck(Bird):
    def fly(self) -> None:
        print("¡El pato está volando!")


class Ostrich(Bird):
    def fly(self) -> None:
        # Las avestruces no vuelan
        raise NotImplementedError("Las avestruces no pueden volar")

Qualquer código que pressuponha que Toda ave que consegue voar fracassará quando receber um avestruz.. Isto é, Ostrich Não é um substituto válido para Bird, violando assim o LSP.

A solução é ajustar a hierarquia para refletir melhor a realidade: nem todos os pássaros voam, então Apenas uma parte das aves deve seguir o método. fly():

class Bird:
    pass


class FlyingBird(Bird):
    def fly(self) -> None:
        pass


class Duck(FlyingBird):
    def fly(self) -> None:
        print("¡El pato está volando!")


class Ostrich(Bird):
    # No vuela, así que no implementa fly()
    pass

Com este design, Qualquer função que exija um pássaro voador declarará que precisa de um. FlyingBirde nunca receberá um avestruz. Dessa forma, o LSP é respeitado e exceções inesperadas em tempo de execução são evitadas.

LSP e conversas sobre pássaros

Voltando ao exemplo das conversas, é comum começar a programar pensando apenas em patos e depois querer adicionar corvos ou outros pássaros. Se a classe de conversa depender de Duck, Você não poderá reutilizá-lo com outros tipos de pássaros. Sem alterar o código:

class Crow:
    # Implementación específica del cuervo
    ...

Si SimpleConversation A tipagem é específica para patos; você não conseguirá aplicar um corvo a ela sem modificá-la. A abordagem correta é criar uma abstração comum. Bird e fazer com que a conversa dependa dessa abstração:

from abc import ABC, abstractmethod


class Bird(ABC):
    def __init__(self, name: str):
        self.name = name

    @abstractmethod
    def do_sound(self) -> str:
        pass


class Crow(Bird):
    def do_sound(self) -> str:
        return "Caw"


class Duck(Bird):
    def do_sound(self) -> str:
        return "Quack"


class SimpleConversation(AbstractConversation):
    def __init__(self, bird1: Bird, bird2: Bird):
        self.bird1 = bird1
        self.bird2 = bird2

    def do_conversation(self) -> list:
        sentence1 = f"{self.bird1.name}: {self.bird1.do_sound()}, hello {self.bird2.name}"
        sentence2 = f"{self.bird2.name}: {self.bird2.do_sound()}, hello {self.bird1.name}"
        return 

Dessa forma, qualquer subclasse de Bird que respeita o contrato (do_sound()(nome, etc.) é um substituto válido e não interromperá o comportamento esperado de SimpleConversation.

I – Princípio de Segregação de Interface

O princípio do ISP sustenta que Nenhum cliente deve ser obrigado a depender de métodos que não utiliza.Traduzindo para classes abstratas ou interfaces, isso significa que é melhor ter várias interfaces pequenas e específicas do que uma interface enorme e genérica.

Observe este design em que uma interface Worker Exige que todos os que a implementam tenham métodos específicos de trabalho e alimentação:

from abc import ABC, abstractmethod


class Worker(ABC):
    @abstractmethod
    def work(self) -> None:
        pass

    @abstractmethod
    def eat(self) -> None:
        pass


class Human(Worker):
    def work(self) -> None:
        print("El humano está trabajando")

    def eat(self) -> None:
        print("El humano está comiendo")


class Robot(Worker):
    def work(self) -> None:
        print("El robot está trabajando")

    def eat(self) -> None:
        # El robot no come, pero está obligado a declarar este método
        pass

A aula O robô depende de um método eat() que não precisaQualquer mudança relacionada à alimentação afetará o robô, mesmo que não tenha nada a ver com esse comportamento.

Ao aplicar o ISP, dividimos a interface em duas menores e mais específicas:

class Workable(ABC):
    @abstractmethod
    def work(self) -> None:
        pass


class Eatable(ABC):
    @abstractmethod
    def eat(self) -> None:
        pass


class Human(Workable, Eatable):
    def work(self) -> None:
        print("El humano está trabajando")

    def eat(self) -> None:
        print("El humano está comiendo")


class Robot(Workable):
    def work(self) -> None:
        print("El robot está trabajando")

Agora Cada classe implementa apenas os métodos de que realmente precisa.Isso reduz o acoplamento, facilita a evolução do projeto e torna o código mais expressivo: fica muito claro quem pode fazer o quê.

ISP na modelagem de aves: voo e natação

Algo semelhante acontece ao modelar pássaros que voam e nadam. Se a abstração básica Bird Isso exige a implementação de ambos. fly() como swim()Você vai acabar com aulas como Crow que têm que fingir que sabem nadar:

class Bird(ABC):
    def __init__(self, name: str):
        self.name = name

    @abstractmethod
    def fly(self) -> None:
        pass

    @abstractmethod
    def swim(self) -> None:
        pass

    @abstractmethod
    def do_sound(self) -> str:
        pass

A solução, segundo o provedor de internet, é separar a interface em funcionalidades mais específicas:

class Bird(ABC):
    def __init__(self, name: str):
        self.name = name

    @abstractmethod
    def do_sound(self) -> str:
        pass


class FlyingBird(Bird):
    @abstractmethod
    def fly(self) -> None:
        pass


class SwimmingBird(Bird):
    @abstractmethod
    def swim(self) -> None:
        pass


class Crow(FlyingBird):
    def fly(self) -> None:
        print(f"{self.name} is flying high and fast!")

    def do_sound(self) -> str:
        return "Caw"


class Duck(SwimmingBird, FlyingBird):
    def fly(self) -> None:
        print(f"{self.name} is flying not very high")

    def swim(self) -> None:
        print(f"{self.name} swims in the lake and quacks")

    def do_sound(self) -> str:
        return "Quack"

Se algum dia você decidir fazer um modelo de pinguim, simplesmente você o faz herdar de SwimmingBird mas não de FlyingBirdE você não precisará implementar métodos vazios nem lançar exceções artificiais.

D – Princípio da Inversão de Dependência

O último princípio, DIP, pode ser resumido em duas ideias principais: Módulos de alto nível não devem depender de módulos de baixo nível; ambos devem depender de abstrações.E as abstrações não devem depender dos detalhes, mas sim os detalhes devem depender das abstrações.

Na prática, isso significa que sua lógica de negócios não deve estar atrelada a detalhes específicos como "Eu uso MySQL", "Eu escrevo em um arquivo local" ou "Eu envio mensagens SMS com este provedor". Em vez disso, você define interfaces abstratas (por exemplo, Database, Channel, NotificationService) e você faz com que seu código de alto nível se comunique apenas com eles.

Um design que quebrar DIP Este seria um repositório de usuários que instancia diretamente um banco de dados MySQL:

class MySQLDatabase:
    def connect(self) -> None:
        # Conectar a MySQL
        pass

    def query(self, sql: str) -> list:
        # Ejecutar consulta
        return []


class UserRepository:
    def __init__(self) -> None:
        self.database = MySQLDatabase()  # Dependencia directa

    def get_users(self) -> list:
        return self.database.query("SELECT * FROM users")

Se você decidir usar o PostgreSQL amanhã, você terá que modificar a classe de alto nível UserRepositoryVocê está vinculado a um detalhe de implementação específico.

Ao aplicar o DIP (Processamento de Inclusão de Dados), primeiro definimos uma abstração de banco de dados e, em seguida, fazemos com que as implementações concretas herdem dela:

from abc import ABC, abstractmethod


class Database(ABC):
    @abstractmethod
    def connect(self) -> None:
        pass

    @abstractmethod
    def query(self, sql: str) -> list:
        pass


class MySQLDatabase(Database):
    def connect(self) -> None:
        # Conexión a MySQL
        pass

    def query(self, sql: str) -> list:
        # Consulta en MySQL
        return []


class PostgreSQLDatabase(Database):
    def connect(self) -> None:
        # Conexión a PostgreSQL
        pass

    def query(self, sql: str) -> list:
        # Consulta en PostgreSQL
        return []


class UserRepository:
    def __init__(self, database: Database) -> None:
        self.database = database  # Depende de una abstracción

    def get_users(self) -> list:
        return self.database.query("SELECT * FROM users")

Assim, Você pode injetar qualquer implementação de Database Ao criar o repositório, sem alterar seu código interno:

mysql_db = MySQLDatabase()
user_repo = UserRepository(mysql_db)

postgres_db = PostgreSQLDatabase()
user_repo = UserRepository(postgres_db)

Esse padrão é conhecido como Injeção de Dependência E essa é a forma mais comum de aplicar o DIP: as classes não criam suas próprias dependências, mas as recebem de fora (através do construtor ou de métodos específicos), sempre usando abstrações como tipo.

DIP aplicado a canais e comunicadores

No exemplo das conversas entre pássaros, também podemos melhorar o gerenciamento de canais aplicando DIP (Processamento de Inferência Dinâmica). Suponha que você defina uma abstração para o canal e outra para o comunicador:

class AbstractChannel(ABC):
    @abstractmethod
    def get_channel_message(self) -> str:
        pass


class AbstractCommunicator(ABC):
    @abstractmethod
    def get_channel(self) -> AbstractChannel:
        pass

    @final
    def communicate(self, conversation: AbstractConversation) -> None:
        print(*conversation.do_conversation(),
              self.get_channel().get_channel_message(),
              sep="\n")

Uma primeira implementação, ainda que ingênua, poderia ser:

class SMSChannel(AbstractChannel):
    def get_channel_message(self) -> str:
        return "(via SMS)"


class SMSCommunicator(AbstractCommunicator):
    def __init__(self) -> None:
        self._channel = SMSChannel()  # Depende de detalle concreto

    def get_channel(self) -> AbstractChannel:
        return self._channel

Embora pareça correto, Este comunicador ainda está diretamente acoplado a SMSChannelAprimoramos o projeto fazendo com que o comunicador recebesse o canal de fora (injeção de dependência) e, portanto, dependesse apenas da abstração:

class SimpleCommunicator(AbstractCommunicator):
    def __init__(self, channel: AbstractChannel) -> None:
        self._channel = channel

    def get_channel(self) -> AbstractChannel:
        return self._channel

Com essa abordagem, qualquer novo canal (e-mail, notificações push, etc.) implementa AbstractChannel y Pode ser utilizado sem alterar o código do comunicador.Novamente, as classes de alto nível dependem de abstrações, não de detalhes.

O que acontece quando você ignora o tipo SOLID?

Se esses princípios não forem levados em consideração, o código tende a apresentar problemas como: Código com cheiro ruim, código deteriorado e acoplamentos impossíveis de desfazer.Ou seja, classes enormes com milhares de responsabilidades, subclasses que quebram contratos, dependências cíclicas e métodos que mudam quase que diariamente porque fazem coisas demais.

As consequências são claras e bastante dolorosas para qualquer equipe: Mais vulnerabilidades, mais bugs, refatoração constante e, no pior dos casos, código que acaba se tornando praticamente inutilizável.É o que comumente se chama de "código espaguete": difícil de acompanhar, cheio de remendos e quase impossível de estender sem quebrar algo importante.

Os princípios SOLID não são imutáveis ​​e nem sempre vale a pena aplicá-los rigidamente, especialmente em prototipagem rápida ou projetos muito pequenos. Mesmo assim, Tenha-os em mente e aplique-os à maior parte do seu projeto orientado a objetos em Python. Isso faz toda a diferença entre um projeto que cresce ao longo do tempo e um que desmorona assim que começa a crescer um pouco.

Melhores IDEs para programação do Windows 11
Artigo relacionado:
Os melhores IDEs para programação no Windows 11

Adicionar como fonte preferencial