12/03/2024

Python formatando texto - equilíbrio entre legibilidade e velocidade

Python oferece historicamente várias formas de formatar um texto.

Desde o uso do operador percentual "%", passando pelo método ".format" e finalmente utilizando "f-string".

Desde o lançamento da "f-string", os mantenedores do Python informam que esta é a forma mais rápida de formatar um texto.

A partir de então, sempre que eu trabalhava em uma rotina que utilizava "%" ou ".format", eu convertia para "f-string".

Porém, em um caso específico, e muito comum em vários tipos de aplicação, a legibilidade piorava, em comparação à versão que eu mais utilizava, ".format".

Para melhorar a legibilidade utilizando "f-string" eu acabava precisando adicionar mais linhas de código fonte.

Como isso acontecia em funções executadas muitas vezes, achei melhor testar o impacto dessa mudança na performance da função.

Aproveitei e medi também a performance das formas mais antigas de formatação.

Ao final dos testes, tive que escolher entre legibilidade e velocidade.

Resolvi trazer para vocês a análise que fiz, pois pode ser útil para outros programadores em contextos similares.

--

Mas vamos por partes.

Primeiro, para os iniciantes em Python, vou mostrar, bem basicamente, as 3 formas citadas de formatação de um texto, utilizando um exemplo simples, mas que já encaminha para o entendimento do caso a ser testado.

Este é um caso fictício, porém, inspirado em um caso real, encontrado no ERP utilizado por uma empresa em que trabalho.

Neste exemplo fictício, uma fábrica de brinquedos utiliza um código de produto fabricado composto de 4 partes, listadas abaixo com exemplos de valores:

  • Tipo: "CARRO"
  • Grupo: "BMW"
  • Subgrupo: "ROADSTER"
  • Item: "Z3"

Essas partes são concatenadas para formar o código do produto, utilizando alguns separadores predefinidos.

Seguindo o exemplo acima, o código inteiro do produto ficaria: "CARRO:BMW_ROADSTER-Z3"

Segue um exemplo, em Python, de como formatar o código do produto das 3 formas citadas acima:

---

tipo = "CARRO"
grupo = "BMW"
subgrupo = "ROADSTER"
modelo = "Z3"

# utilizando %

codigo = "%s:%s_%s-%s" % ( tipo, grupo, subgrupo, modelo)

# utilizando .format

codigo = "{}:{}_{}-{}".format( tipo, grupo, subgrupo, modelo)

# utilizando f-string

codigo = f"{tipo}:{grupo}_{subgrupo}-{modelo}"

---

Nesse exemplo simples vemos que a opção "f-string" é a mais legível. Como também sabemos que é a mais rápida, seria a escolha óbvia.

Porém, na minha aplicação, essas partes do código do produto não estão disponíveis da forma apresentada acima, diretamente em variáveis, mas sim em um dicionário de dados, que é uma estrutura muito utilizada em Python, parecida com o "HashMap" do Java.

Geralmente essas informações estão em uma tabela de banco de dados, e é processada no meu programa como uma lista de dicionários. Cada item na lista representa um brinquedo. Cada brinquedo tem suas características, atributos, armazenados em um dicionário. Por exemplo:

---

brinquedo = {
'tipo': "CARRO",
'grupo': "BMW",
'subgrupo': "ROADSTER",
'modelo': "Z3",
# outros atributos
'preço': 100,
'estoque': 200,
# ...
}

---

Para acessar os valores dos atributos, a sintaxe em Python é assim:

--- 

brinquedo['tipo']
brinquedo['grupo']
brinquedo['subgrupo']
brinquedo['modelo']
---

Utilizando o dicionário acima as 3 formas de montar o código ficam assim:

---

# utilizando %

codigo = "%s:%s_%s-%s" % (brinquedo['tipo'], brinquedo['grupo'], brinquedo['subgrupo'], brinquedo['modelo'])

# utilizando .format

codigo = "{}:{}_{}-{}".format(brinquedo['tipo'], brinquedo['grupo'], brinquedo['subgrupo'], brinquedo['modelo'])

# utilizando f-string

codigo = f"{brinquedo['tipo']}:{brinquedo['grupo']}_{brinquedo['subgrupo']}-{brinquedo['modelo']}"

--- 

A legibilidade de todas as opções ficou prejudicada, mas há uma forma simples de melhoras isso ao utilizar ".format", que apresento abaixo:

---

# utilizando .format otimizado para um dicionário

codigo = "{tipo}:{grupo}_{subgrupo}-{modelo}".format(**brinquedo)

---

Entre as 4 opções acima, a mais legível é a do ".format" otimizado. Porém já temos a informação de que não é a opção mais rápida.

Para utilizar a forma mais rápida, "f-string", melhorando a legibilidade, tive que fazer uma versão adicionando mais linhas de código fonte ao programa, desta forma:

---

# utilizando f-string com variáveis simples representando os valores no dicionário

tipo = brinquedo['tipo']
grupo = brinquedo['grupo']
subgrupo = brinquedo['subgrupo']
modelo = brinquedo['modelo']
codigo = f"{tipo}:{grupo}_{subgrupo}-{modelo}"

---

Esta versão é legível, está utilizando a forma recomendada como mais veloz, porém tem 4 linhas a mais de código fonte. Qual será o impacto disso na velocidade total de formatação desejada?

Para verificar isso, utilizei a biblioteca "timeit" do Python e fiz o seguinte programa:

---

import timeit
from string import Template

def usando_fstring(brinquedo):
    return f"{brinquedo['tipo']}:{brinquedo['grupo']}_{brinquedo['subgrupo']}-{brinquedo['modelo']}"

def usando_percent(brinquedo):
    return "%s:%s_%s-%s" % (brinquedo['tipo'], brinquedo['grupo'], brinquedo['subgrupo'], brinquedo['modelo'])

def usando_format(brinquedo):
    return "{}:{}_{}-{}".format(brinquedo['tipo'], brinquedo['grupo'], brinquedo['subgrupo'], brinquedo['modelo'])

def usando_format_otimizado(brinquedo):
    return "{tipo}:{grupo}_{subgrupo}-{modelo}".format(**brinquedo)

def usando_fstring_com_variaveis(row):
    tipo = brinquedo['tipo']
    grupo = brinquedo['grupo']
    subgrupo = brinquedo['subgrupo']
    modelo = brinquedo['modelo']
    return f"{tipo}:{grupo}_{subgrupo}-{modelo}"


def teste(funcao, brinquedo, num_execucoes, base_de_comparacao=None):
    tempo_funcao = timeit.timeit(lambda : funcao(brinquedo), number=num_execucoes)
    percentual = tempo_funcao/base_de_comparacao*100 if base_de_comparacao else 100
    print(f"{tempo_funcao:.4f} segundos {funcao.__name__} {percentual:.1f}%")
    return tempo_funcao

brinquedo = {
    'tipo': "CARRO",
    'grupo': "BMW",
    'subgrupo': "ROADSTER",
    'modelo': "Z3",
}

num_execucoes = 100_000_000

base_de_comparacao = teste(usando_fstring, brinquedo, num_execucoes)
teste(usando_percent, brinquedo, num_execucoes, base_de_comparacao)
teste(usando_format, brinquedo, num_execucoes, base_de_comparacao)
teste(usando_format_otimizado, brinquedo, num_execucoes, base_de_comparacao)
teste(usando_fstring_com_variaveis, brinquedo, num_execucoes, base_de_comparacao)

---

Não vou analisar detalhadamente o uso do "timeit" sente artigo. Por ora, vou apenas descrever brevemente o programa e analisar o resultado.

Para cada uma das 5 opções de formatação do código do produto abordadas anteriormente, criei uma função que recebe o dicionário, "brinquedo", e devolve o código do produto formatado.

Criei uma função de teste de performance que, além de imprimir o tempo gasto na execução de cada função, imprime também a comparação, em percentual, deste tempo com o tempo do uso recomendado da "f-string".

Para melhorar a comparação, cada função é executada 100 milhões de vezes.

Ao executar o programa em modo texto esse é o resultado impresso na tela:

---

usando_fstring: 19.7958 segundos = 100.0%
usando_percent: 23.0266 segundos = 116.3%
usando_format: 35.9589 segundos = 181.6%
usando_format_otimizado: 71.4536 segundos = 361.0%
usando_fstring_com_variaveis: 22.7871 segundos = 115.1%

---

O tempo pode variar, dependendo do computador onde é executado, por isso, mais importante que analisar os segundos é analisar o percentual.

Vemos que, das opções com melhor legibilidade, que são as duas últimas, fica muito claro que a solução que utiliza "f-string" com linhas de código fonte extras, para atribuir valores do dicionário a variáveis, é a melhor opção.

Porém, fique claro, "melhor opção" para o meu caso específico naquele momento, em que eu estava trabalhando em telas e relatórios que executariam funções utilizando a técnica escolhida algumas milhares de vezes. Além disso, é importante saber que essas telas e relatórios seriam utilizados por menos de uma centena de pessoas da empresa.

Se eu estivesse fazendo o processamento de um lote de dados grande, com muitos milhões de registros, a importância da performance seria maior do que da legibilidade e, com certeza, eu escolheria a opção que utiliza "f-string" diretamente com os atributos do dicionário. Ou alguma opção não abordada aqui, específica para engenheria de dados.

Da mesma forma, se eu estivesse fazendo esse mesmo tipo de telas e relatórios, porém para um sistema fosse utilizado por milhões de pessoas, a importância da performance também seria maior do que a da legibilidade.

--

Enfim, esse foi um exemplo de análise que pode definir um padrão de estilo de escrita de um programa.

É sempre bom estarmos atentos à performance dos programas criados, tanto visando economizar tempo em uma rotina demorada, quanto visando economizar recursos dos servidores, quando uma rotina rápida é utilizada por milhões de usuários.

Porém, na prática, muitos são os casos não se enquadram nesses, ou outros, exemplos de processamento pesado. Nesses casos podemos priorizar a legibilidade do código fonte, que trará benefícios na revisão, na manutenção e na evolução do sistema criado. O que também é uma forma de economizar recursos.

--

Outra lição que não deve ser ignorada: Quando os mantenedores de uma linguagem recomendam priorizar uma abordagem em detrimento de outra, pode acreditar que, para casos genéricos, essa recomendação deve ser seguida.

De qualquer forma, sempre busque entender o motivo da recomendação, para saber se há casos específicos em que não é válida.

Repetindo a recomendação dos mantenedores, em Python, como testado neste artigo, priorize o uso de "f-string", em detrimento do uso de "%" ou ".format".

--

Links:

Nenhum comentário:

Postar um comentário