Strings em Profundidade: métodos, formatação e expressões regulares

Strings em Profundidade: métodos, formatação e expressões regulares

Texto é o dado que mais chega torto. Além do repertório de métodos, formatação e do módulo re, veja três armadilhas que falham em silêncio: strip removendo caracteres em vez de sufixo, isdigit aceitando o que int recusa, e o cifrão que deixa passar quebra de linha num validador.
Python

• • 21 min de leitura

No artigo A História do Python e os Primeiros Passos vimos que strings são sequências imutáveis de caracteres. Mas há muito mais a explorar. Texto é o tipo de dado mais comum no mundo real — nomes, endereços, mensagens, HTML, JSON, logs de sistema. Dominar a manipulação de strings em Python é uma habilidade indispensável no dia a dia profissional.

Revisão: Strings são Sequências

linguagem = "Python"

print(len(linguagem))      # 6
print(linguagem[0])        # P
print(linguagem[-1])       # n
print(linguagem[1:4])      # yth
print(linguagem[::-1])     # nohtyP

# Iterando
for letra in linguagem:
    print(letra, end=" ")  # P y t h o n

Que a string seja imutável tem uma consequência que aparece em código de verdade: todo método de transformação devolve uma string nova e nunca altera a original. Não existe s.upper() que mude s no lugar — se o resultado não for atribuído, ele se perde. É a diferença em relação à lista, onde sort() altera e devolve None.

Daí nasce o conselho de nunca montar texto com += dentro de um laço, que é verdade pela metade e merece o número medido. Concatenando vinte mil vezes na 3.14:

+= sozinho no laço           0,0152 s
+= com outra referência viva 0,0836 s
''.join(...)                 0,0107 s

O += sozinho é quase tão rápido quanto o join porque o CPython tem uma otimização que reaproveita o buffer quando a string tem uma única referência. Basta alguém guardar uma segunda referência — um anterior = s inocente dentro do laço — para a otimização evaporar e o custo saltar cinco vezes. Não é comportamento garantido pela linguagem, é detalhe da implementação. Por isso o join continua sendo a forma a usar: não é só mais rápido, é previsivelmente mais rápido.

Métodos de Transformação

texto = "  Olá, Mundo!  "

print(texto.strip())        # "Olá, Mundo!"   — remove espaços das bordas
print(texto.lstrip())       # "Olá, Mundo!  " — remove à esquerda
print(texto.rstrip())       # "  Olá, Mundo!" — remove à direita

s = "python é incrível"
print(s.upper())            # PYTHON É INCRÍVEL
print(s.lower())            # python é incrível
print(s.capitalize())       # Python é incrível
print(s.title())            # Python É Incrível

print(s.replace("incrível", "poderoso"))
# python é poderoso

Duas armadilhas destes métodos

A primeira derruba código de manipulação de arquivos o tempo todo: strip(), lstrip() e rstrip() não removem um sufixo. Eles recebem um conjunto de caracteres e vão retirando qualquer um deles, da borda para dentro, enquanto houver correspondência.

"mytext.txt".rstrip(".txt")   # 'myte'   — levou o t e o x de "mytext"
"test.txt".rstrip(".txt")     # 'tes'
"hello.txt".rstrip(".txt")    # 'hello'  — este acertou por acaso

O hello.txt devolver exatamente o esperado é o que mantém o defeito escondido: o método parece funcionar até chegar um nome terminado em letra que esteja no conjunto. Desde a 3.9 existe o método que faz o que se queria — "mytext.txt".removesuffix(".txt") devolve 'mytext' —, e há removeprefix() para o outro lado.

A segunda é o title(), que capitaliza toda palavra e não conhece as partículas do português:

"maria da silva".title()   # 'Maria Da Silva' — o "da" não deveria subir

Para nome próprio o title() quase nunca é a resposta; o caminho é uma função que respeite a lista de partículas do idioma.

Métodos de Busca

frase = "Aprender Python é aprender a pensar"

print(frase.find("Python"))       # 9  — índice da primeira ocorrência
print(frase.find("Java"))         # -1 — não encontrado
print(frase.index("Python"))      # 9  — igual a find, mas lança ValueError se não achar
print(frase.count("aprender"))    # 2  — conta ocorrências (case-sensitive)
print(frase.startswith("Aprender"))  # True
print(frase.endswith("pensar"))      # True
print("Python" in frase)            # True

Métodos de Divisão e União

csv = "Ana,Bruno,Carla,Diego"
nomes = csv.split(",")
print(nomes)  # ['Ana', 'Bruno', 'Carla', 'Diego']

# Split com limite
partes = "a:b:c:d".split(":", 2)
print(partes)  # ['a', 'b', 'c:d']

# Dividindo por linhas
texto = "linha1\nlinha2\nlinha3"
linhas = texto.splitlines()
print(linhas)  # ['linha1', 'linha2', 'linha3']

# Unindo com join
palavras = ["Python", "é", "incrível"]
frase = " ".join(palavras)
print(frase)  # Python é incrível

caminho = "/".join(["home", "usuario", "documentos"])
print(caminho)  # home/usuario/documentos

Formatação de Strings

Python oferece três formas principais de formatar strings. A mais moderna e recomendada é a f-string, introduzida no Python 3.6.

f-strings (recomendado)

nome = "Ana"
nota = 9.75
aprovada = True

print(f"Aluna: {nome}")
print(f"Nota: {nota:.2f}")         # 9.75 — 2 casas decimais
print(f"Nota: {nota:.1f}")         # 9.8
print(f"Aprovada: {aprovada}")

# Expressões dentro de f-strings
x = 10
print(f"O dobro de {x} é {x * 2}")

# Alinhamento e preenchimento
print(f"{'Produto':<15} {'Preço':>8}")
print(f"{'Teclado':<15} {299.90:>8.2f}")
print(f"{'Mouse':<15} {89.90:>8.2f}")

Saída:

Produto            Preço
Teclado           299.90
Mouse              89.90

Vale ler a especificação de formato, porque é ela que torna a tabela previsível. Em {'Produto':<15}, o < alinha à esquerda e o 15 é a largura do campo; em {299.90:>8.2f}, o > alinha à direita, o 8 é a largura e o .2f fixa duas casas decimais. A contagem é de caracteres, não de bytes, e por isso Preço ocupa cinco posições mesmo com o cedilha. E largura é mínimo, não teto: um valor mais comprido que o campo não é cortado — ele empurra a coluna e desalinha a tabela inteira. Quando o corte é desejado, é preciso pedi-lo, com {texto:.15}.

str.format() (Python 3.0+)

mensagem = "Olá, {}! Você tem {} mensagens.".format("Bruno", 5)
print(mensagem)

# Com nomes
template = "Aluno: {nome}, Nota: {nota:.1f}"
print(template.format(nome="Carla", nota=8.5))

% formatting (legado — evite em código novo)

print("Olá, %s! Nota: %.2f" % ("Diego", 9.0))

Strings Multilinha e Raw Strings

# Multilinha com \n
consulta = "SELECT *\nFROM usuarios\nWHERE ativo = 1"

# Multilinha com aspas triplas — preserva formatação
consulta = """
SELECT *
FROM usuarios
WHERE ativo = 1
"""

# Raw string — ignora sequências de escape
# Muito usada para expressões regulares e caminhos Windows
caminho = r"C:\Users\Ricardo\Documentos"
print(caminho)  # C:\Users\Ricardo\Documentos

Verificações Úteis

print("12345".isdigit())      # True
print("Python".isalpha())     # True
print("Python3".isalnum())    # True
print("  ".isspace())         # True
print("python".islower())     # True
print("PYTHON".isupper())     # True
print("Título".istitle())     # True

Essas verificações parecem óbvias e escondem uma armadilha de validação. O isdigit() responde sobre a categoria Unicode do caractere, não sobre a possibilidade de virar número:

"²".isdigit()      # True  — mas int("²") levanta ValueError
"١٢٣".isdigit()    # True  — e int("١٢٣") devolve 123, em algarismos arábicos
"12.5".isdigit()   # False — apesar de ser um número perfeitamente válido
"-3".isdigit()     # False — o sinal não é dígito

Para a pergunta que de fato se quer fazer — isto vira inteiro? — o teste mais próximo é isdecimal(), que recusa o ² e aceita os algarismos arábicos. Ainda assim, validar entrada numérica testando antes é o caminho errado: o honesto é tentar converter e tratar a falha, com try e except ValueError, o que resolve de uma vez o sinal, o espaço em volta e os separadores.

Expressões Regulares

Para buscas e transformações complexas em texto, Python oferece o módulo re. Expressões regulares (regex) são padrões que descrevem conjuntos de strings.

import re

texto = "Contato: joao@email.com ou maria@empresa.com.br"

# Buscando um padrão
padrao = r"[\w.+-]+@[\w-]+\.[\w.]+"
emails = re.findall(padrao, texto)
print(emails)  # ['joao@email.com', 'maria@empresa.com.br']

Funções principais do módulo re:

import re

texto = "Python 3.12 foi lançado em 2023"

# re.search() — encontra primeira ocorrência, retorna objeto Match ou None
match = re.search(r"\d+\.\d+", texto)
if match:
    print(match.group())   # 3.12
    print(match.start())   # 7 — índice inicial
    print(match.end())     # 11 — índice final

# re.findall() — retorna lista com todas as ocorrências
numeros = re.findall(r"\d+", texto)
print(numeros)  # ['3', '12', '2023']

# re.sub() — substitui ocorrências
resultado = re.sub(r"\d+", "X", texto)
print(resultado)  # Python X.X foi lançado em X

# re.split() — divide usando um padrão
partes = re.split(r"[,;]\s*", "Ana, Bruno; Carla,Diego")
print(partes)  # ['Ana', 'Bruno', 'Carla', 'Diego']

# re.compile() — compila padrão para reutilização eficiente
padrao_cep = re.compile(r"\d{5}-\d{3}")
print(padrao_cep.match("01310-100"))  # Match object
print(padrao_cep.match("abc"))        # None

Principais metacaracteres:

.       qualquer caractere (exceto \n)
\d      dígito [0-9]
\w      caractere de palavra [a-zA-Z0-9_]
\s      espaço em branco
^       início da string
$       fim da string
*       zero ou mais repetições
+       uma ou mais repetições
?       zero ou uma repetição
{n}     exatamente n repetições
{n,m}   entre n e m repetições
[abc]   qualquer um dos caracteres listados
[^abc]  qualquer caractere exceto os listados
(abc)   grupo de captura

Exemplo Completo: Validador de Dados

import re

def validar_email(email):
    # \Z, e não $: veja a ressalva logo abaixo do exemplo
    padrao = r"^[\w.+-]+@[\w-]+\.[a-zA-Z]{2,}\Z"
    return bool(re.match(padrao, email))

def validar_cpf_formato(cpf):
    padrao = r"^\d{3}\.\d{3}\.\d{3}-\d{2}\Z"
    return bool(re.match(padrao, cpf))

def validar_telefone(telefone):
    padrao = r"^\(?\d{2}\)?\s?\d{4,5}-?\d{4}$"
    return bool(re.match(padrao, telefone))

def formatar_cep(cep):
    """Remove formatação e reaplica no padrão correto."""
    apenas_numeros = re.sub(r"\D", "", cep)
    if len(apenas_numeros) == 8:
        return f"{apenas_numeros[:5]}-{apenas_numeros[5:]}"
    return None


testes_email = ["ana@email.com", "invalido@", "outro@dominio.com.br"]
for email in testes_email:
    status = "✓" if validar_email(email) else "✗"
    print(f"{status} {email}")

print()

testes_cpf = ["123.456.789-09", "12345678909", "abc.def.ghi-jk"]
for cpf in testes_cpf:
    status = "✓" if validar_cpf_formato(cpf) else "✗"
    print(f"{status} {cpf}")

print()

ceps = ["01310100", "01310-100", "1310-100"]
for cep in ceps:
    resultado = formatar_cep(cep)
    print(f"{cep} → {resultado}")

Por que \Z e não $ num validador

Este é o tipo de detalhe que passa em toda revisão e vira brecha de segurança. Em expressões regulares Python, o $ casa no fim da string ou logo antes de uma quebra de linha final. Um validador fechado com $ aceita, portanto, um valor com \n pendurado no fim:

padrao = r"^[\w.+-]+@[\w-]+\.[a-zA-Z]{2,}$"

bool(re.match(padrao, "ana@email.com"))     # True
bool(re.match(padrao, "ana@email.com\n"))   # True  ← o problema

Parece inofensivo até esse valor ser gravado num cabeçalho de e-mail, numa linha de log ou num arquivo CSV, onde a quebra de linha muda o significado do que vem depois. Trocando o $ por \Z, que casa apenas no fim absoluto da string, o segundo caso passa a ser recusado. Vale notar ainda que o ^ é redundante com re.match, que já ancora no início — quem usa re.search é que precisa dos dois. E que validar formato de CPF não é validar CPF: os dígitos verificadores exigem conta, não padrão.

Texto é o tipo de dado que mais chega torto, e por isso o repertório deste artigo se usa todo dia: cortar, buscar, dividir, juntar, formatar e, quando o padrão fica complicado demais para um método simples, recorrer ao módulo re. O fio que amarra tudo é a imutabilidade — nenhum método altera a string original, todos devolvem uma nova, e o resultado que não for atribuído se perde. É também o que explica por que join é a forma de montar texto em laço: não por ser sempre mais rápido, mas por ser previsivelmente rápido, enquanto o += depende de uma otimização do CPython que some assim que outra referência à string existe.

Três armadilhas deste artigo valem mais que o resto da lista de métodos, porque falham em silêncio. O strip() não remove sufixo, e sim qualquer caractere do conjunto que recebe, de modo que rstrip(".txt") devolve myte a partir de mytext.txt — quem quer remover sufixo usa removesuffix(). O isdigit() responde sobre categoria Unicode e não sobre conversão, aceitando ², que int() recusa; validar número é tentar converter e tratar a falha. E, num validador com expressão regular, o $ casa também antes de uma quebra de linha final, deixando passar valores com \n pendurado — o fim absoluto se escreve \Z.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Um script renomeia arquivos removendo a extensão com nome.rstrip(".txt"). Rodou sobre 4.000 arquivos de um acervo. A maioria saiu certa, mas cerca de duzentos ficaram com o nome truncado: relatorio_texto.txt virou relatorio_tex e contrato.txt virou contrato, que estava certo. Explique o critério que separou os dois grupos e diga qual método deveria ter sido usado.

Ver resposta

✓ Resposta: O rstrip() não remove um sufixo: ele recebe um conjunto de caracteres e vai retirando, da direita para a esquerda, qualquer caractere que pertença a esse conjunto, até encontrar um que não pertença. O argumento ".txt" não significa a cadeia .txt; significa o conjunto {'.', 't', 'x'}. Por isso "mytext.txt".rstrip(".txt") devolve 'myte': removeu o .txt, continuou e comeu o t e o x de mytext, parando só no e. O critério que separou os dois grupos, então, é qual letra sobrou no fim do nome depois de tirar a extensão: contrato termina em o, que não está no conjunto, e o processo parou na hora certa; relatorio_texto termina em o... mas antes dele vinha t, e a remoção só para no primeiro caractere fora do conjunto, de modo que nomes terminados em t, x ou ponto foram mutilados. Esse é o pior formato de defeito possível: funciona na maioria dos casos e falha numa minoria, o que faz o teste passar e a revisão aprovar. Desde a 3.9 existe o método que faz exatamente o que se pretendia: nome.removesuffix(".txt"), que remove a cadeia inteira se ela estiver no fim e devolve a string intacta se não estiver, sem tocar em mais nada. Há removeprefix() para o outro lado. Para lidar com caminhos, porém, o melhor é não fazer à mão: pathlib.Path(nome).stem devolve o nome sem a extensão, e .suffix devolve a extensão, tratando os casos de nome com vários pontos que qualquer solução caseira erra.

Exercício 2

Um formulário valida o e-mail com re.match(r"^[\w.+-]+@[\w-]+\.[a-zA-Z]{2,}$", email) e grava o valor aprovado num arquivo de log, uma linha por cadastro. Um analista percebeu que o log tem linhas a mais do que cadastros, e que algumas parecem conter texto que ninguém digitou num campo de e-mail. Explique a falha e corrija a expressão.

Ver resposta

✓ Resposta: Em expressões regulares Python, $ não significa fim da string: significa fim da string ou a posição imediatamente anterior a uma quebra de linha final. Isso faz com que re.match(padrao, "ana@email.com\n") devolva um match válido, e o validador aprove um valor que carrega um \n pendurado. Enquanto o valor ficou só em memória ou num banco, ninguém notou; ao ser escrito num log de uma linha por cadastro, cada valor desses produziu duas linhas, e o que vinha depois da quebra apareceu como se fosse uma entrada própria. É por isso que o log tem linhas a mais e com conteúdo que ninguém reconhece — e a mesma falha em um cabeçalho de e-mail ou numa resposta HTTP é uma injeção de verdade, não um incômodo de contagem. A correção é trocar $ por \Z, que casa apenas no fim absoluto: com r"^[\w.+-]+@[\w-]+\.[a-zA-Z]{2,}\Z", o valor com \n passa a ser recusado. Vale corrigir dois detalhes de conhecimento junto. O ^ é redundante quando se usa re.match, que já ancora no começo por definição — quem precisa das duas âncoras explícitas é quem usa re.search. E validar com re.fullmatch dispensa as duas, porque exige que o padrão consuma a string inteira, o que costuma ser a intenção real e é mais difícil de escrever errado. A lição de fundo é que a entrada precisa ser normalizada antes de validada: um strip() no valor recebido teria evitado este caso, e evita a classe toda de problemas com espaço e quebra de linha invisíveis.

Exercício 3

Uma rotina aceita quantidade digitada pelo usuário e faz if valor.isdigit(): n = int(valor). Em produção surgiram dois problemas opostos: pedidos com quantidade negativa e com decimais foram rejeitados como inválidos, e uma linha específica passou pela checagem mas explodiu com ValueError na conversão. Explique os dois, e diga como validar direito.

Ver resposta

✓ Resposta: O isdigit() não responde à pergunta que se está fazendo. Ele informa se todos os caracteres pertencem à categoria Unicode de dígito, o que é coisa diferente de o texto poder virar número. Daí os dois problemas. Do lado das rejeições: "-3".isdigit() é False, porque o sinal não é dígito, e "12.5".isdigit() também é False, porque o ponto não é — ambos são números perfeitamente válidos que a checagem descartou. Do lado oposto, existem caracteres que são dígitos para o Unicode e que int() não aceita: "²".isdigit() devolve True e int("²") levanta ValueError, que é exatamente a linha que explodiu. Há ainda um caso que costuma surpreender mais: "١٢٣".isdigit() é True e int("١٢٣") devolve 123, porque os algarismos arábicos são decimais de verdade — o Python converte, e o sistema recebe um número que ninguém digitou naquele teclado. Se a intenção é apenas saber se int() vai funcionar, o teste mais próximo é isdecimal(), que recusa o ². Mas a resposta melhor é abandonar a verificação prévia: envolver a conversão em try e except ValueError cobre de uma vez o sinal, o espaço em volta, o separador de milhar e todo caractere exótico, sem precisar antecipar a lista. É o princípio de pedir perdão em vez de permissão, e aqui ele não é estilo, é corretude — a checagem antecipada e a conversão usam critérios diferentes, e onde eles divergem nasce o defeito.

Exercício 4

Um programador normaliza nomes com nome.strip() e nome.upper() em duas linhas seguidas, e depois grava nome. Os dados saem sem nenhuma normalização. Ele corrige, e em seguida um colega aponta que o laço que monta o relatório usa texto += linha e sugere trocar por join, alegando que a concatenação é quadrática. Avalie as duas coisas.

Ver resposta

✓ Resposta: O primeiro problema é imutabilidade, e é de entendimento, não de digitação. String em Python não muda: strip() e upper() não alteram nome, eles devolvem uma string nova. Chamar o método sem atribuir o resultado joga fora o trabalho, e o Python não reclama, porque descartar um valor de retorno é legítimo. A correção é nome = nome.strip().upper(). Vale contrastar com a lista, onde o engano é simétrico e oposto: lista.sort() altera no lugar e devolve None, de modo que lista = lista.sort() destrói a lista. A regra prática que resolve os dois: método de string devolve, método de lista que altera devolve None. Sobre a segunda observação, o colega tem razão na conclusão e erra no motivo — e o motivo importa. A concatenação em laço não é quadrática no CPython atual: existe uma otimização que reaproveita o buffer da string quando ela tem uma única referência viva, e medindo vinte mil concatenações na 3.14 o += sozinho leva 0,0152 s contra 0,0107 s do join, diferença de 1,4 vez. Só que essa otimização é frágil e não é garantia da linguagem: basta existir outra referência à string — um anterior = texto inocente dentro do laço, um valor guardado para log — e ela deixa de valer, o custo pula para 0,0836 s, cinco vezes mais. Então a recomendação de usar join está certa, e o argumento honesto não é que o += é sempre lento: é que o desempenho dele depende de um detalhe de implementação que uma linha distante pode desligar sem aviso. O join é previsível, e previsibilidade é o que se quer.

Exercício 5

Uma tabela de produtos é impressa com f"{nome:<15} {preco:>8.2f}" e sai perfeitamente alinhada nos testes. Em produção, algumas linhas aparecem desalinhadas e empurram a coluna de preço para a direita. O time cogita aumentar 15 para 30. Explique o que está acontecendo e diga por que a solução proposta apenas adia o problema.

Ver resposta

✓ Resposta: Na especificação de formato, o número indica largura mínima, não largura fixa. Em {nome:<15}, o < alinha à esquerda e o 15 garante que o campo terá ao menos quinze posições, completando com espaços quando o valor for menor. Quando o valor é maior, nada é cortado: ele é impresso inteiro e empurra tudo o que vem depois. Foi o que aconteceu — os testes usavam nomes curtos, e a produção tem nomes de produto com mais de quinze caracteres. Aumentar para 30 não resolve, apenas move a fronteira: no dia em que aparecer um nome de 31 caracteres, o desalinhamento volta, e no meio-tempo a tabela fica com um vão enorme em todas as linhas normais. A correção é dizer explicitamente o que fazer com o excedente, e há duas respostas conforme a intenção. Truncar: {nome:<15.15}, em que o segundo número é a precisão, que para texto significa o máximo de caracteres a usar — assim o campo nunca passa de quinze. Ou preservar o nome e calcular a largura a partir dos dados, com largura = max(len(p) for p in nomes) e f"{nome:<{largura}}", já que a especificação aceita largura vinda de variável. Truncar em silêncio tem seu próprio custo, e quando o nome completo importa costuma valer cortar com reticência explícita. Um detalhe que ajuda a depurar esse tipo de saída: a contagem é de caracteres, não de bytes, então acento e cedilha ocupam uma posição cada — Preço conta cinco. O que de fato quebra o alinhamento visual, mesmo com a contagem certa, são emoji e ideogramas, que ocupam duas colunas no terminal e uma posição na contagem do Python.

Comentários

Mais em Python

Tuplas e Sets: imutabilidade e unicidade
Tuplas e Sets: imutabilidade e unicidade

Tuplas e sets parecem variações da lista, mas resolvem problemas distintos…

Introdução ao Desenvolvimento Web com Flask
Introdução ao Desenvolvimento Web com Flask

Flask começa com uma rota e uma função, e os erros aparecem no que ele deixa…

Laços de Repetição: for e while
Laços de Repetição: for e while

O for do Python não conta: percorre. A diferença parece cosmética e muda o…