Esse artigo é quase um repeteco dos artigos Dicas para um bom programa em Python e o Personal Python Style Guide. Mas aqui mostro como uso o processo de refatoração do código para estudar e entendê-lo.
Estou tentando entender um código bem intrincado e importante do trabalho… é um código bem crítico que resolve um problema bem difícil (merge de objetos) e foi desenvolvido “sob demanda”, ou seja, fizeram uma versão básica e foram incrementando ele com cenários diversos que foram sendo descobertos com o tempo.
Todo mundo na empresa tem medo de mexer com esse código porque, apesar dele ter até uma certa cobertura de testes, não sabemos se esses testes realmente cobrem todos os cenários reais.
Mas preciso entender esse código para fazer uma otimização (ele executa um UPDATE
muito demorado no nosso banco de dados e eu preciso remover esse UPDATE
).
Eu não sei onde esse UPDATE
acontece porque o código é todo elaborado com execuções tardias (lazy) das operações. Então preciso ler tudo para entender onde essa operação está sendo agendada para execução.
Como tenho TDAH é muito difícil, para mim, somente ler o código para entendê-lo. Quando o código é curto e simples tudo bem, mas não é esse o caso. O que eu geralmente faço é um processo de refatoração do estilo do código. Não só em termos de formatação (porque tem ferramentas para isso que já rodam no nosso sistema de integração contínua), mas também em estilo estrutural.
Vou separando cada bloco de refatoração ou função refatorada em um commit em um branch criado especificamente para esse trabalho.
Uma vez que terminei tudo e entendi o funcionamento do código crio um PR (em modo draft) com uma descrição detalhada do que fiz, da separação em commits, necessidade de revisar, como revisar, etc. para meus colegas de trabalho avaliarem e até mesmo responder algumas das minhas dúvidas. Mas o mais importante: inicio a descrição do PR explicando que a aceitação dele é totalmente opcional e até mesmo não indicada por conta dos riscos envolvidos.
Para todo esse processo é imprescindível o uso de uma ferramenta de refatoração automática que possibilite renomear identificadores, extrair funções/métodos, inversão de lógica em if
s, etc. Senão o seu trabalho será miserável.
Munido de todos os requisitos passo a alterar o código da seguinte maneira…
Nomes melhores
Entender um código onde os identificadores são chamados obj
, data
, tmp
, etc. é complicado. Ter identificadores com nomes como “foo_json
” que tem um dict()
e não uma JSON-string também não ajuda muito.
Renomeie variáveis, funções, métodos, classes, etc. para terem sempre o nome correto. Se estiver difícil escolher um nome para o identificador é porque o código tem outro tipo de problema ou ainda falta compreensão sobre ele.
Early Return
Uso early return pattern para reduzir o volume de indentação do código e o embaraçamento dele (tangled). O objetivo é linearizar os fluxos e criar blocos segmentados com lógica de processamento. Sou um “Never Nester Developer”. 😀
Como já comentei nesse artigo aqui, de modo bem simplificado, o código abaixo:
def f(c1, ..., cN):
if c1 and... cN:
... faz um monte de coisas ...
return 1
return 0
Vira algo do tipo:
def f(c1, ..., cN):
# cenário excepcional 1
if not c1:
return 0
...
# cenário excepcional N
if not cN:
return 0
... faz um monte de coisas ...
return 1
O código fica mais longo, mas é possível identificar bloco a bloco quais são as condições excepcionais da função em cada bloco.
Lembrem-se que o objetivo aqui não é eficiência e sim a legibilidade e compreensão do código.
Ajustes de if
‘s, elif
‘s e else
‘s
Nessa etapa o objetivo é eliminar o máximo de if
‘s, elif
‘s e else
‘s do código (e adicionar else
‘s quando temos algum elif
‘s inevitável).
Uma forma de fazer isso é inicializando valores em certas variáveis e só modificá-los dentro do if
. Mas nem sempre isso basta e, em alguns casos, quando cada bloco é muito grande ou tem chamadas de funções, etc. sequer é possível de ser feito.
Um exemplo bem simplório só para ilustrar o que estou dizendo:
if cond:
v = f()
else:
v = default
Vira algo tipo:
v = default
if cond:
v = f()
Dessa forma deixo no fluxo normal a condição padrão e crio um branch só para tratar de uma excepcionalidade.
Essa refatoração pode realmente ficar enorme e o resultado também pode ficar pior que o original, logo, use com moderação.
Outra refatoração que faço não tem relação somente com a compreensão do código, mas até mesmo com o funcionamento correto dele: se você tem if
e elif
é prudente ter um else
. Mesmo que seja para levantar um erro. Afinal, se você pensou em mais de um cenário, o que acontece com aquele cenário que você não pensou?
Falo sobre isso abordagem aqui e aqui.
Extração de código
Outra refatoração que ajuda bastante a melhorar a legibilidade do código é conhecida como Extract Method. Ela possibilita trocar um trecho de código por uma chamada de função que descreve o que esse código faz.
Para fazer essa refatoração é bem útil ter uma ferramenta que automatize o processo. A IDE que uso no dia a dia oferece essa refatoração, mas é provável que existam plugins para vários outros editores e IDEs.
Essa é fácil de ilustrar:
def f(x, y):
# Verifica se o objeto x é válido
valid = False
if x.a and x.a == 0:
valid = True
if x.compare(y):
valid = True
if valid:
... faz algo ...
Vira algo assim:
def is_valid(x, y):
if not x.a or x.a != 0:
return False
if not x.compare(y):
return False
return True
def f(x, y):
if not is_valid(x,y):
return
... faz algo ...
Quando você está lendo o código de f(x,y)
você, sabe que o objeto x
é validado primeiro e o resto da função só será executado quando o objeto x
for válido.
Exceções devem ser exceptions
É muito comum ver funções retornando flags (ex. None
) quando ela precisa sinalizar um problema, um erro ou uma exceção.
Considerando que idealmente uma função (ou método) deve retornar sempre objetos de um mesmo tipo, o retorno de None
deveria ser algo ruim, certo?
Quando retornamos None em nossas funções precisamos ficar verificando todos os valores retornados antes de usá-los, ou seja, toda hora vemos os famigerados:
ret = f()
if ret:
... faz algo ...
else:
... trata o erro ...
Em linguagens com suporte a exceções podemos usá-las para sinalizar problemas ou… excepcionalidades!
try:
ret = f()
exception UmaExcecaoBemEspecificaQueFPodeGerar:
... trata o erro ...
... faz algo ...
Essa refatoração melhora a legibilidade do código porque deixa o tratamento da exceção bem perto do código que pode gerar ela. E para isso ser verdade é importante que o bloco try/except
realmente seja pequeno e restrito ao trecho onde a exceção pode acontecer.
Também é importante que a exceção gerada (e tratada) sejam sempre bem específicas para o erro gerado para evitar tratar o erro inadequadamente.
Usar bem a linguagem
O código da empresa onde trabalho é escrito em Python e então eu refatoro ele para ficar mais “pythônico” (o que quer que isso signifique para mim).
Legibilidade é melhor que eficiência nesse momento.
Prefiro um belo “if
-zão” bem legível a uma “if
–expression” toda muvucada.
Um loop pode funcionar melhor que um comprehension… estou ajustando o código para ler e entendê-lo e não para que ele rode um femtossegundo mais rápido.
Tipos
Tipos (e eventualmente anotação de tipos) podem auxiliar na compreensão do código, bem como as ferramentas de refatoração automática.
Tente padronizar os tipos de parâmetros e retornos das funções. Tente fazer com que eles sempre recebam e retornem objetos dos mesmos tipos. E lembre-se também que None
é do tipo NoneType
e não do mesmo tipo dos objetos que você está querendo usar. 😜
Uma função que busca uma pessoa pelo nome:
def get_pessoa(name):
pessoas = Pessoa.filter(name=name)
if not pessoas:
return None
return pessoas[0]
Ficaria assim:
def get_pessoa(name: str) -> Pessoa:
pessoas = Pessoa.filter(name=name)
if not pessoas:
raise PessoaNaoEncontrada(name)
if len(pessoa) > 1:
raise MultiplasPessoasComNome(name)
return pessoa
Namespaces para contextualizar
Quando esbarro com muitos nomes que vem do mesmo módulo, tento refatorar o uso deles para incluir o nome do módulo de origem no namespace:
from contants import (
FOO,
BAR,
BAZ,
QUX,
DUX,
)
def f(x):
if x == FOO: ...
if x == BAR: ...
if x == BAZ: ...
if x == QUX: ...
if x == DUX: ...
Vira:
import constants
def f(x):
if x == constants.FOO: ...
if x == constants.BAR: ...
if x == constants.BAZ: ...
if x == constants.QUX: ...
if x == constants.DUX: ...
Dessa forma trago o contexto de qual módulo os identificadores vem.
Métodos próximos dos objetos
Esse faço pouco porque o time onde trabalho não curte “Fat Models” do mesmo jeito que eu gosto.
Mas essencialmente transformo quaisquer funções auxiliares que lidam especificamente com um tipo de objeto em um método do próprio objeto e tiro da frente especificidades que aquele objeto pode encapsular para mim.
Mover trechos de código e funções que lidam com um tipo específico de objeto como método do próprio objeto.
def nome_completo(pessoa):
return f"{pessoa.primeiro_nome} {pessoa.ultimo_nome}"
def etiqueta(pessoa):
return {
"nome": nome_completo(pessoa),
"endereco": pessoa.endereco,
}
Fica:
class Pessoa:
@property
def nome_completo(self):
return f"{self.primeiro_nome} {self.ultimo_nome}"
def etiqueta(pessoa):
return {
"nome": pessoa.nome_completo,
"endereco": pessoa.endereco,
}
Assim, ao analisar a função etiqueta() foco especificamente em como ela funciona sem me distrair com código de concatenação de nomes.
OOP e não DOP
Dicionários são estruturas de dados tão poderosas em Python que é bem fácil a gente começar a usá-las para tudo em nosso código. Mas isso começa a se tornar um problema com o tempo porque é quase impossível encapsular os dados de um dicionário com o objetivo garantir a consistência deles.
Enquanto estou fazendo a refatoração de um código, começo a usar namedtuples
, dataclasses
ou até mesmo uma classe “convencional” (com métodos e tudo) para substituir os dicionários que ficam espalhados pelo código.
def grava_pessoa(dados_pessoa: dict) -> int:
if not valida_dados_pessoa(dados_pessoa):
raise DadosInvalidos(dados_pessoa)
# se tem 'id' já existe no DB
if dados_pessoa.get("id"):
return update(dados_pessoa) # retorna o id
return insert(dados_pessoa) # retorna o id
Ficaria mais ou menos assim:
class Pessoa:
def __init__(self, nome, ...):
self.nome = nome
...
def valida(self):
... # valida dados do objeto
def grava_pessoa(pessoa: Pessoa) -> int: # ou Pessoa
if not pessoa.valida():
raise DadosInvalidos(pessoa)
if pessoa.id:
return update(pessoa) # retorna id ou Pessoa()
return insert(pessoa) # retorna id ou Pessoa()
A lógica de validação do objeto não fica me distraindo do que a função grava_pessoa()
faz: inserir ou atualizar os dados da pessoa no banco de dados.
TODO/FIXME
Uso comentários TODO/FIXME com dúvidas que não consigo solucionar lendo somente o código que estou mexendo e que não posso esquecer de perguntar para algum colega em algum momento no futuro (lembrem que tenho TDAH e esquecerei as dúvidas que tenho).
# Ignora registro de pessoas afetados pelo bug de migração
if 32000 > pessoa.id > 10000:
return
Provavelmente vira algo assim:
# TODO (osantana): o código abaixo ainda é necessário mesmo
após a migração?
# Ignora registro de pessoas afetados pelo bug de migração
if 32000 > pessoa.id > 10000:
return
Estilo de código
Por último, quando não vejo muita coisa para melhorar no código, eu mudo só algumas coisas menores no estilo de código para forçar algum tipo de formatação pelas ferramentas automáticas de formatação.
# formatador faz algo assim:
x = funcao_com_nome_longo_e_muitos_parametros(
a="foo", b="bar", c="baz", d="qux)
Coloco uma “,
” para forçar a indentação abaixo:
# formatador faz algo assim:
x = funcao_com_nome_longo_e_muitos_parametros(
a="foo",
b="bar",
c="baz",
d="qux,
)
Conclusão
Vocês têm alguma outra coisa que você também faça para poder entender algum código mais cabuloso?
O espaço abaixo é todo seu…