Sua suíte de testes passa. Seu verificador de tipos está verde. Você faz o deploy. Duas horas depois, a produção lança um IndexError em um caso extremo que ninguém pensou em testar.

Testes encontram bugs. Tipos previnem alguns. Nenhum dos dois prova que seu programa está livre de erros de execução. Para isso, você precisa de algo mais forte: uma forma de raciocinar sobre todas as execuções possíveis, todas de uma vez, sem executar o código.

A interpretação abstrata é a técnica que torna isso possível. Ela alimenta os analisadores estáticos dentro do Astrée, do Infer do Facebook e as garantias de solidez do borrow checker do Rust. Ela também explica por que a maioria do marketing de “zero falsos positivos” para análise estática é uma mentira.

Erros de execução são um problema de alcançabilidade

Um erro de execução é apenas uma operação que alcança um estado ruim. Divisão por zero, dereferência de nulo, estouro de buffer, índice fora dos limites. Cada um acontece porque a execução chega a um ponto do programa onde a operação é insegura.

Para provar que nenhum erro de execução existe, você precisa provar que toda operação insegura é inalcançável em todas as execuções possíveis. Toda entrada. Todo ramo. Toda iteração de loop.

Testes exaustivos são impossíveis para qualquer coisa além de programas de brinquedo. A execução simbólica não escala bem porque a explosão de caminhos te mata. A interpretação abstrata segue uma rota diferente: ela desiste de saber valores exatos e, em vez disso, rastreia propriedades aproximadas que garantidamente cobrem toda execução real.

O que a interpretação abstrata realmente faz

A interpretação abstrata foi introduzida por Patrick e Radhia Cousot em 1976. A ideia central é lindamente simples: execute seu programa, mas em vez de computar com números reais, strings e ponteiros, você computa com representações abstratas que sobreamostram os valores reais.

Pense nisso como substituir sua execução concreta por uma execução “sombra” que rastreia propriedades das quais você se importa. Em vez de saber que x = 42, você pode saber que x > 0. Em vez de saber que arr tem comprimento 10, você pode saber que arr não está vazio.

A restrição chave é a solidez. Todo estado concreto deve ser representado por algum estado abstrato. Se a execução abstrata diz que uma operação é segura, então toda execução concreta que ela representa é segura. O preço é a precisão: se o estado abstrato é muito vago, você obtém falsos positivos (avisos espúrios sobre operações seguras).

Um exemplo concreto: análise de intervalos

Aqui está um interpretador abstrato mínimo que prova que a divisão por zero é impossível. Ele rastreia o intervalo possível de cada variável usando intervalos.

# A tiny abstract interpreter for interval analysis
from dataclasses import dataclass
from typing import Dict

@dataclass(frozen=True)
class Interval:
    lo: float
    hi: float

    def __contains__(self, val: float) -> bool:
        return self.lo <= val <= self.hi

TOP = Interval(float("-inf"), float("inf"))

def eval_expr(env: Dict[str, Interval], expr) -> Interval:
    if isinstance(expr, int):
        return Interval(float(expr), float(expr))
    if isinstance(expr, str):
        return env.get(expr, TOP)
    if expr[0] == "+":
        l = eval_expr(env, expr[1])
        r = eval_expr(env, expr[2])
        return Interval(l.lo + r.lo, l.hi + r.hi)
    if expr[0] == "-":
        l = eval_expr(env, expr[1])
        r = eval_expr(env, expr[2])
        return Interval(l.lo - r.hi, l.hi - r.lo)
    if expr[0] == "*":
        l = eval_expr(env, expr[1])
        r = eval_expr(env, expr[2])
        products = [l.lo * r.lo, l.lo * r.hi, l.hi * r.lo, l.hi * r.hi]
        return Interval(min(products), max(products))
    if expr[0] == "/":
        l = eval_expr(env, expr[1])
        r = eval_expr(env, expr[2])
        if r.lo <= 0 <= r.hi:
            raise ValueError("Possible division by zero")
        return TOP
    raise ValueError(f"Unknown expr: {expr}")

def merge_envs(env1: Dict[str, Interval], env2: Dict[str, Interval]) -> Dict[str, Interval]:
    keys = set(env1) | set(env2)
    result = {}
    for k in keys:
        a = env1.get(k, TOP)
        b = env2.get(k, TOP)
        result[k] = Interval(min(a.lo, b.lo), max(a.hi, b.hi))
    return result

def analyze(program, env: Dict[str, Interval]) -> Dict[str, Interval]:
    env = dict(env)
    for stmt in program:
        if stmt[0] == "assign":
            _, var, expr = stmt
            env[var] = eval_expr(env, expr)
        elif stmt[0] == "if":
            _, cond, true_branch, false_branch = stmt
            t_env = analyze(true_branch, dict(env))
            f_env = analyze(false_branch, dict(env))
            env = merge_envs(t_env, f_env)
        elif stmt[0] == "while":
            _, cond, body = stmt
            old = dict(env)
            for _ in range(10):
                new = analyze(body, dict(old))
                changed = False
                for k in set(old) | set(new):
                    prev = old.get(k, TOP)
                    curr = new.get(k, TOP)
                    widened = Interval(min(prev.lo, curr.lo), max(prev.hi, curr.hi))
                    if widened.lo != prev.lo or widened.hi != prev.hi:
                        changed = True
                    old[k] = widened
                if not changed:
                    break
            env = old
    return env

# Example program: y = 10 / (x + 1) where x >= 0
program = [
    ("assign", "t", ("+", "x", 1)),
    ("assign", "y", ("/", 10, "t")),
]

# This should pass: x >= 0 means t >= 1, so no division by zero
safe_env = analyze(program, {"x": Interval(0, 100)})
print("Safe env:", safe_env)

# This should fail: x could be -1
try:
    bad_env = analyze(program, {"x": Interval(-5, 5)})
except ValueError as e:
    print("Caught:", e)

Execute-o. O primeiro caso prova segurança porque o intervalo para t é [1.0, 101.0], que exclui o zero. O segundo caso sinaliza corretamente o risco porque t pode ser zero quando x é -1.

O manipulador de if funde ambos os ramos tomando a união dos intervalos. O manipulador de while itera até que os intervalos parem de crescer (um ponto fixo). Esse é o coração da interpretação abstrata: você troca valores exatos por sobreamostrações garantidas, e prova segurança mostrando que o estado ruim fica fora da aproximação.

Onde isso falha: a barreira da precisão

O exemplo acima é do tamanho de um brinquedo. Programas reais têm aliasing, recursão, alocações de heap e loops com limites dependentes de dados. Cada um destrói a precisão de formas previsíveis.

Considere um loop que incrementa i de 0 a 100. Uma análise de intervalo ingênua pode ampliar i para [0, +inf) e nunca recuperar o limite superior. Você precisa de domínios relacionais (como poliedros ou octógonos) para rastrear que i <= 100. Esses domínios são cúbicos ou exponenciais no número de variáveis. Para um programa com 1.000 variáveis, você não está executando uma análise de poliedros.

É por isso que ferramentas comerciais fazem escolhas diferentes. O Astrée usa uma treliça cuidadosamente escolhida de domínios abstratos, ajustada manualmente para C embarcado. O Infer usa lógica de separação e bi-abdução para escalar a milhões de linhas de código mobile, mas desiste da solidez para alguns recursos da linguagem. O borrow checker do Rust é essencialmente um interpretador abstrato com um único domínio extremamente preciso: propriedade.

Compromissos que você não pode evitar

Solidez, precisão e escalabilidade. Escolha dois.

Um analisador sólido com alta precisão não escalará além de small modules. Um analisador escalável e sólido vai te afogar em falsos positivos. Um analisador escalável e preciso vai deixar passar bugs reais.

Sua escolha de domínio abstrato é o botão de ajuste. Intervalos são rápidos e imprecisos. Poliedros são precisos e lentos. A abstração por predicados fica no meio e alimenta a maioria dos verificadores de modelos de software.

Como usar isso na prática

Você provavelmente não escreverá seu próprio interpretador abstrato. Você usará um que já existe.

Para C e sistemas embarcados, Astrée e Frama-C são as opções maduras. O plug-in EVA do Frama-C realiza análise de intervalos e memória em código C do mundo real.

Para Rust, o sistema de tipos já codifica um domínio abstrato de propriedade. O Miri é um interpretador, não um interpretador abstrato, mas ele captura comportamento indefinido que o sistema de tipos deixa passar.

Para código de propósito geral, o Infer da Meta é a coisa mais próxima de um interpretador abstrato escalável para Java, C++ e Objective-C. Ele encontra bugs reais de dereferência de nulo e vazamento de memória em production codebases.

Se você quer experimentar, comece com uma análise simples de sinais ou de intervalos em uma linguagem que você mesmo parseie. O Dragon Book cobre análise de fluxo de dados. O Principles of Program Analysis de Nielson e Nielson é a referência padrão para interpretação abstrata especificamente.

Provando o negativo

A interpretação abstrata não tornará seu código livre de bugs. O que ela te dá é um framework matemático para provar que classes específicas de erros de execução são impossíveis. Essa prova é tão boa quanto seu domínio abstrato, sua estratégia de ampliação e sua disposição para tolerar falsos positivos.

A maioria das equipes obtém mais valor de bons testes e de um sistema de tipos sólido. Mas quando você está escrevendo código onde um erro de execução significa que um satélite cai do céu, a interpretação abstrata é como você dorme à noite.