SOLID: open/closed principle em Go e Python
Esse é o segundo artigo de uma série sobre SOLID explicado com exemplos nas linguagens de programação Python e Go, da forma mais simples posssível, utilizando dois livros como base, um do Corey Scott e outro do Mariano Anaya:
Para atualizar os exemplos de Python do “Clean Code in Python” utilizaremos também o artigo do Real Python. Aqui iremos falar sobre o open/closed principle, ou princípio aberto/fechado em português. Perguntas para guiar nosso estudo:
- O que é e para que serve o open/closed principle?
- Como aplicar o open/closed principle em Python?
- Como aplicar o open/closed principle em Go?
O que é e para que serve o open/closed principle?
Você já deve ter ouvido a frase “aberto para extensão, fechado para modificação”, mas talvez não
tenha entendido perfeitamente o que isso quer dizer. Quando estamos escrevendo uma classe, um módulo,
etc, queremos que seja possível extendê-lo porque requisitos mudam e ao longo do tempo sempre surgem
novas features, novas funcionalidades e comportamentos para o nosso software. Quando extender um
software é muito difícil ou parece impossível de ser feito, temos um problema. Em outras palavras,
queremos que seja fácil adicionar comportamentos, mas não queremos modificar o que já existe (podendo
quebrar funcionalidades), e é justamente para isso que serve o princípio open/closed.
Corey Scott sobre esse princípio:
Essas duas características podem parecer contraditórias, mas a peça que falta no quebra-cabeça é o escopo. Quando falamos em ser aberto, estamos falando do design ou da estrutura do software. Dessa perspectiva, ser aberto significa que é fácil adicionar novos pacotes, novas interfaces ou novas implementações de uma interface existente.
Como bem sublinha Mariano Anaya, o princípio open/closed é aplicável a diversas abstrações de software, podendo ser uma classe, módulo, função, etc, a ideia é a mesma.
Como aplicar o open/closed principle em Python?
O Mariano Anaya dá um exemplo interessante em seu livro, acompanhe meus comentários no código dele:
from dataclasses import dataclass
# O decorator `dataclass` dá a classe Python uma série de métodos
# e atributos básicos built-in sem que a gente precise escrevê-los
# na mão, como um `__init__(self, raw_data: dict)`, por exemplo
@dataclass
class Event:
"""`Event` é uma classe que define um evento"""
raw_data: dict
# Cada classe abaixo herda do `Event` e especifica um tipo de evento.
# Imagine uma implementação hipotética do que cada uma faz ou tem
class UnknownEvent(Event):
pass
class LoginEvent(Event):
pass
class LogoutEvent(Event):
pass
class SystemMonitor:
"""`SystemMonitor` identifica eventos na aplicação"""
# Inicializando o `SystemMonitor` com um atributo `event_data`
def __init__(self, event_data):
self.event_data = event_data
# Método para identificar um evento: se é um `LoginEvent`,
# `LogoutEvent` ou `UnknownEvent`
def identify_event(self):
if (
self.event_data["before"]["session"] == 0
and self.event_data["after"]["session"] == 1
):
return LoginEvent(self.event_data)
elif (
self.event_data["before"]["session"] == 1
and self.event_data["after"]["session"] == 0
):
return LogoutEvent(self.event_data)
return UnknownEvent(self.event_data)
Agora, imagine que queremos adicionar mais 3 eventos a essa aplicação. Você diria que o SystemMonitor
está “aberto para extensão, fechado para modificação”? Pense bem na parte de “modificação”. Para extender
o monitoramento, além da inevitável adição de um novo evento (nova subclasse) precisaríamos modificar o
SystemMonitor
com um novo elif
. Para conseguir cumprir o princípio precisamos, de alguma forma,
fazer com que a lógica do SystemMonitor
“seja uma só” e que a “responsabilidade” da nova feature
seja justamente do novo código, do novo evento. Uma forma de fazer isso é como no código abaixo:
from dataclasses import dataclass
# A classe `Event` é a nossa classe "genérica"
@dataclass
class Event:
raw_data: dict
# Queremos abstrair a identificação do evento, e aqui
# exemplificamos uma interface comum para todos os
# eventos seguirem
@staticmethod
def meets_condition(event_data: dict):
return False
class UnknownEvent(Event):
pass
class LoginEvent(Event):
# Agora cada nova subclasse de `Event` implementa seu
# próprio `meets_condition`. Esse método recebe um
# dicionário `event_data` e computa uma lógica para
# definir se é o tipo de evento em questão ou não
@staticmethod
def meets_condition(event_data: dict):
return (
event_data["before"]["session"] == 0
and event_data["after"]["session"] == 1
)
class LogoutEvent(Event):
@staticmethod
def meets_condition(event_data: dict):
return (
event_data["before"]["session"] == 1
and event_data["after"]["session"] == 0
)
# Novo evento. Repare em como basta implementar sua lógica,
# seguindo a "interface" da classe genérica
class TransactionEvent(Event):
@staticmethod
def meets_condition(event_data: dict):
return event_data["after"].get("transaction") is not None
class SystemMonitor:
def __init__(self, event_data):
self.event_data = event_data
def identify_event(self):
# Agora para identificar o evento usamos o método
# mágico (dunder/magic method) `__subclasses__()`
# para "olhar" as subclasses do `Event`
for event_cls in Event.__subclasses__():
try:
# Chamando a implementação de clada subclasse
# passando o `event_data`, e a partir daí a
# lógica é responsabilidade de cada evento
if event_cls.meets_condition(self.event_data):
# Se satisfaz a condição para ser o evento,
# retorne a subclasse com os dados do
# evento
return event_cls(self.event_data)
except KeyError:
continue
return UnknownEvent(self.event_data)
Com o exemplo acima não precisamos ficar alterando o SystemMonitor
toda vez que um evento novo
for criado, bastará apenas criar uma nova subclasse e implementar a interface da classe genérica
Event
.
Poxa, mas não tem um exemplo mais moderno e mais simples não, sem usar
__subclasses__()
?
Exatamente o que pensei ao ler o código, e pensei logo no pacote abc
do Python. Logo em seguida o
autor Mariano Anaya citou a possiblidade de fazer de outras formas, justamente com o abc
. Vejamos
o exemplo do site Real Python:
from math import pi
class Shape:
# Inicializamos a class recebendo um `shape_type` e `kwargs`
def __init__(self, shape_type, **kwargs):
self.shape_type = shape_type
# Para definir `height` e `width` vamos de `if` e `elif`
# até dizer chega
if self.shape_type == "rectangle":
self.width = kwargs["width"]
self.height = kwargs["height"]
elif self.shape_type == "circle":
self.radius = kwargs["radius"]
# Método para calcular a área a depender do tipo de forma
def calculate_area(self):
if self.shape_type == "rectangle":
return self.width * self.height
elif self.shape_type == "circle":
return pi * self.radius**2
Consegue ver o padrão se repetir aqui? Adicionar mais tipos de formas, de shapes, faz com que tenhamos
que modificar a classe com mais if
. Podemos utilizar essa classe para criar diferentes formas, e
ela funciona, mas não segue o princípio open/closed. Uma possível solução:
from abc import ABC, abstractmethod
from math import pi
# Finalmente, estamos usando o pacote `abc`! Na nossa
# classe genérica, `Shape`, também chamada de classe abstrata,
# e herdamos de `ABC`
class Shape(ABC):
def __init__(self, shape_type):
self.shape_type = shape_type
# E aqui criamos um método abstrato com o `abstractmethod`.
# Quem quiser que implemente 😒💅
@abstractmethod
def calculate_area(self):
pass
# `Circle`, um tipo de `Shape`, herda de `Shape`, nossa
# classe abstrata
class Circle(Shape):
def __init__(self, radius):
# Chamando o construtor da superclasse, passando
# o tipo de forma
super().__init__("circle")
self.radius = radius
# Implementando o `calculate_area` do `Circle`
def calculate_area(self):
return pi * self.radius**2
class Rectangle(Shape):
def __init__(self, width, height):
super().__init__("rectangle")
self.width = width
self.height = height
# Implementando o `calculate_area` do `Rectangle` e
# por aí vai. Quem pegou o bonde andando não senta na
# janela: implemente sua poha
def calculate_area(self):
return self.width * self.height
Agora podemos dizer que nosso Shape
aberto para extensão e fechado para modificação.
Como aplicar o open/closed principle em Go?
Agora vamos ver como isso funciona em Go. Vejamos um exemplo do autor Corey Scott de uma função que formata um output a depender do tipo do arquivo:
func BuildOutput(r http.ResponseWriter, format string, p Person) {
var err error
switch format {
case "csv":
err = outputCSV(r, p)
case "json":
err = outputJSON(r, p)
}
if err != nil {
r.WriteHeader(http.StatusInternalServerError)
return
}
r.WriteHeader(http.StatusOK)
}
// Função específica hipotética para csv
func outputCSV(writer io.Writer, person Person) error {
// TODO: implementar
return nil
}
// Função específica hipotética para json
func outputJSON(writer io.Writer, person Person) error {
// TODO: implementar
return nil
}
type Person struct {
Name string
Email string
}
Se quisermos trabalhar com outro formato, xml
, por exemplo, teremos que fazer algumas coisas
nesse código acima:
- Adicionar mais um
case
para cada novo formato; - Adicionar novas funções de output específicas;
- Os métodos que chamam essa função precisam ser adaptados;
- Novos testes precisarão ser adicionados.
Geralmente, quanto mais coisas precisamos modificar no código para abarcar a mudança, mais difícil fica de não quebrar algo. Existe uma outra forma de escrever esse código:
func BuildOutput(r http.ResponseWriter, pf PersonFormatter, p Person) {
// Chamamos um método hipotético Format de uma interface
// PersonFormatter
err := pf.Format(r, p)
if err != nil {
r.WriteHeader(http.StatusInternalServerError)
return
}
r.WriteHeader(http.StatusOK)
}
// Definindo comportamento/contrato através de uma interface
type PersonFormatter interface {
Format(writer io.Writer, person Person) error
}
type CSVPersonFormatter struct{}
// Implementação do Format para csv
func (c *CSVPersonFormatter) Format(writer io.Writer, person Person) error {
// TODO: implement
return nil
}
type JSONPersonFormatter struct{}
// Implementação do format para JSON
func (j *JSONPersonFormatter) Format(writer io.Writer, person Person) error {
// TODO: implement
return nil
}
type Person struct {
Name string
Email string
}
No código acima abstraímos o switch
por uma “caixa preta”: definimos um comportamento e quem
quiser implementar esse comportamento basta criar seu código específico, seguindo as “regras” da
interface. No fim das contas, cortamos a necessidade de ficar adicionando uma lista enorme de switch
e precisamos alterar um lugar a menos no código. Quanto menos lugares para alterar, melhor.
O Corey Scott menciona um benefício interessante do open/closed principle: ajudar a reduzir o escopo
de novos bugs para apenas o novo código, o que faz bastante sentido já que se adicionarmos um novo
formato, como o xml
, já sabemos onde precisamos olhar se os testes quebrarem:
...
type XMLPersonFormatter struct{}
func (c *XMLPersonFormatter) Format(writer io.Writer, person Person) error {
// Se passou a quebrar depois do PR que implementa isso
// aqui, estamos diante de uma implementação safada com
// bug
return nil
}
Flash da Jequiti para lembrar os princípios:

Fontes:
ANAYA, Mariano. Clean Code in Python. Packt, 2021.
SCOTT, Corey. Hands-On Dependency Injection in Go. Packt, 2018.
SOLID Principles: Improve Object-Oriented Design in Python - Real Python