Foto do livro Beautiful Code sobre uma mesa com uma iluminação quente vinda de um abajur.

Código Cabuloso

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 ifs, 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 “ifexpression” 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?


Comentários

6 respostas para “Código Cabuloso”

  1. Andre Sionek

    Ótimo artigo! Obrigado por compartilhar!

    1. Valeu André!

  2. Roger Demetrescu

    Vira algo assim:

    def is_valid(x, y):
    valid = False # essa linha não está sobrando não?
    if not x.a or x.a != 0:
    return False

    if not x.compare(y):
    return False

    return True

    1. Tá sim hehe 🙂 ja já arrumo 🙂

  3. John Doe Santos

    Esse é um dos artigos que você pode deixar envelhecer no barril por anos que sempre terá um sabor excelente para degustar no futuro. Parabéns pelo artigo e pela conexão com o leitor. Descreveu com maestria o dia a dia de bons programadores.

    1. Obrigado! Eu fiquei feliz de ver que recentemente o meu “ídolo” Kent Beck publicou um livro comentando que ele faz algo bem parecido comigo 🙂

O espaço abaixo é todo seu…