Moacyr Francischetti Corrêa

1 Módulo 02: Projeto do Professor — Da Cadeia à Expressão Regular, e a Especificação Léxica da Peneira

Este é o projeto de referência resolvido pelo professor: a mesma atividade que cada grupo vai executar neste módulo, feita por inteiro, com as decisões justificadas uma a uma. Use como modelo do que a sua especificação deve parecer — as categorias da sua linguagem são outras, e as decisões sobre elas serão suas.

1.1 Visão Geral do Módulo 02

O módulo 2 pede uma coisa só do grupo, e ela não tem código: especificar as categorias léxicas da própria linguagem em notação de expressões regulares, com os conjuntos de cadeias que devem ser aceitas e rejeitadas por cada uma. É uma entrega de documento, e é a primeira vez que a intuição do módulo anterior precisa virar precisão.

Só que o projeto do professor tem um segundo dever, além de resolver a atividade: ele é a fonte única de código do módulo, e precisa dar corpo executável a toda a teoria apresentada. E a teoria deste módulo é implementável quase inteira — operações sobre cadeias, operações sobre linguagens, a sintaxe das expressões regulares e a sua semântica definida por indução. Nada disso aparece no compilador da Peneira ainda, mas tudo isso pode ser rodado, e rodar muda o entendimento.

Vamos então resolver a atividade e, em torno dela, construir o ferramental que torna a teoria conferível. Começo pelas cadeias, subo para as linguagens, chego às expressões regulares com a sua semântica, e só então escrevo a especificação léxica da Peneira — que é a entrega propriamente dita. A ordem não é arbitrária: a especificação usa o vocabulário construído antes dela.

Uma escolha de projeto atravessa o módulo e vale antecipar. Tudo que representa linguagem aqui é finito e limitado por comprimento. O fecho de Kleene de um conjunto não vazio é infinito, e não existe estrutura de dados que o guarde por extensão. O que fazemos é gerar a fatia até um comprimento dado. Isso é aproximação de demonstração, não implementação do conceito — e é exatamente a limitação que o módulo 3 resolve, quando o autômato aparece e passa a representar o conjunto infinito em espaço finito. Deixar essa insuficiência visível agora é o que torna o autômato uma resposta, e não uma novidade arbitrária.

1.2 Tarefa 1: A especificação léxica da Peneira

A atividade — identificar as categorias de símbolos léxicos da linguagem e descrever cada uma com precisão total, acompanhada dos corpora de aceitação e rejeição.

Comecei pela pergunta que a atividade realmente faz: quais são os tipos de “palavra” que um programa Peneira contém? Olhando o programa de exemplo do módulo 1, encontro nomes, números, textos entre aspas, padrões entre barras, sinais de pontuação e espaços. São seis categorias.

A decisão de projeto que tomei aqui, e que recomendo, foi escrever a especificação como código em vez de como prosa solta. A partir do módulo 7, é essa especificação que o analisador léxico consome. Se ela viver só num documento de texto, documento e implementação divergem na primeira alteração — e divergem em silêncio.

02_lexico.h
#ifndef PENEIRA_02_LEXICO_H
#define PENEIRA_02_LEXICO_H

#include <string>
#include <vector>

#include "02_cadeia.h"
#include "02_regex.h"

namespace peneira {

// Uma categoria léxica da linguagem Peneira: o nome, a expressão regular que
// a descreve (em notação textual) e os dois corpora de verificação.
//
// A especificação vive aqui, em código, e não apenas em prosa, porque a
// partir do módulo 7 é ela que o analisador léxico consome. Documento e
// implementação passam a ser a mesma coisa, e não podem divergir.
struct CategoriaLexica {
    std::string nome;
    std::string notacao;
    std::string observacao;
    std::vector<Cadeia> aceitas;
    std::vector<Cadeia> rejeitadas;
};

// A especificação léxica completa da Peneira.
std::vector<CategoriaLexica> especificacaoLexica();

// Palavras reservadas: casam com a mesma forma de um identificador e precisam
// de desempate por prioridade. O tratamento é do módulo 7; a lista é fixada
// aqui, junto com o resto da especificação.
const std::vector<std::string>& palavrasReservadas();

// Duas categorias ganham também a árvore da expressão regular, e não só a
// notação textual: são as que servem de exemplo de trabalho nos módulos 3 a 5,
// quando a expressão vira autômato. As demais só ganham árvore no módulo 4,
// quando o analisador da notação existir e puder construí-las a partir do
// texto, em vez de à mão.
RegexPtr regexIdentificador();
RegexPtr regexNumero();

}  // namespace peneira

#endif  // PENEIRA_02_LEXICO_H
02_lexico.cpp
#include "02_lexico.h"

#include <utility>

namespace peneira {

const std::vector<std::string>& palavrasReservadas() {
    static const std::vector<std::string> lista{
        "pattern", "rule", "on", "where", "emit", "value", "and", "or",
    };
    return lista;
}

RegexPtr regexIdentificador() {
    // letra ( letra | digito | _ )*
    RegexPtr letra = faixaRegex('a', 'z');
    RegexPtr digito = faixaRegex('0', '9');
    RegexPtr sublinhado = simboloRegex('_');

    RegexPtr corpo = uniaoRegex(uniaoRegex(letra, digito), std::move(sublinhado));
    return concatRegex(faixaRegex('a', 'z'), estrelaRegex(std::move(corpo)));
}

RegexPtr regexNumero() {
    // -? digito+ ( . digito+ )?
    RegexPtr digito = faixaRegex('0', '9');
    RegexPtr sinal = opcionalRegex(simboloRegex('-'));
    RegexPtr inteiro = maisUmRegex(digito);
    RegexPtr fracao = opcionalRegex(
        concatRegex(simboloRegex('.'), maisUmRegex(faixaRegex('0', '9'))));

    return concatRegex(concatRegex(std::move(sinal), std::move(inteiro)),
                       std::move(fracao));
}

std::vector<CategoriaLexica> especificacaoLexica() {
    return std::vector<CategoriaLexica>{
        CategoriaLexica{
            "IDENTIFICADOR",
            "[a-z][a-z0-9_]*",
            "Começa por letra minúscula. Nomes de padrão e variáveis de "
            "ligação. Casa também com as palavras reservadas, que são "
            "desempatadas por prioridade no módulo 7.",
            {"email", "numero", "e", "n2", "valor_alto", "pattern"},
            {"", "2fast", "_oculto", "Email", "com-traco"},
        },
        CategoriaLexica{
            "NUMERO",
            "-?[0-9]+(\\.[0-9]+)?",
            "Sinal opcional, parte inteira obrigatória, parte fracionária "
            "opcional mas com pelo menos um dígito quando presente. Zeros à "
            "esquerda são aceitos: rejeitá-los exigiria uma expressão bem "
            "maior sem ganho para a linguagem.",
            {"0", "100", "-5", "3.14", "-0.5", "007"},
            {"", "-", ".5", "5.", "1.2.3", "1e10", "+3"},
        },
        CategoriaLexica{
            "TEXTO",
            "\"[^\"]*\"",
            "Delimitado por aspas duplas, sem sequências de escape e sem "
            "quebra de linha no interior. A ausência de escape é decisão de "
            "projeto: evita um subanalisador dentro do analisador léxico.",
            {"\"contato\"", "\"\"", "\"valor grande\""},
            {"\"", "\"sem fim", "'aspas simples'"},
        },
        CategoriaLexica{
            "PADRAO",
            "/[^/]*/",
            "O literal de padrão do usuário, delimitado por barras. O texto "
            "interno é a mini-expressão regular que o módulo 4 vai analisar; "
            "aqui ele é apenas reconhecido como um bloco opaco.",
            {"/[a-z]+/", "//", "/-?[0-9]+/"},
            {"/", "/sem fim", "[a-z]+"},
        },
        CategoriaLexica{
            "PONTUACAO",
            "\\(|\\)|\\{|\\}|;|,|=>|==|!=|>=|<=|=|>|<",
            "Inclui os operadores de comparação. Os de dois caracteres criam "
            "a necessidade do casamento mais longo: sem ele, \">=\" seria "
            "lido como \">\" seguido de \"=\". Os parênteses vão escapados "
            "porque na notação eles são agrupamento, não símbolo — a versão "
            "anterior desta linha estava escrita como prosa legível, com "
            "espaços entre as alternativas, e reconhecia a linguagem errada "
            "em silêncio. O defeito só apareceu no módulo 7, quando o "
            "analisador léxico passou a usar este autômato de verdade.",
            {"=", "=>", ">=", "!=", ";", "{"},
            {"", "=>>", "=!", "%"},
        },
        CategoriaLexica{
            "ESPACO",
            "[ \\t\\r\\n]+",
            "Separa símbolos e é descartado pelo analisador léxico, sem virar "
            "símbolo. Precisa existir na especificação mesmo sendo descartado: "
            "é o que permite ao analisador saber onde um identificador termina.",
            {" ", "  ", "\t"},
            {"", "a "},
        },
    };
}

}  // namespace peneira

Cada categoria carrega quatro coisas: o nome, a notação, uma observação que registra a decisão de projeto por trás dela, e os dois corpora. A observação é a parte que mais me custou e a que mais vale, porque é onde ficam as escolhas que ninguém lembraria depois.

Vale percorrer as decisões que a especificação registra.

Identificadores começam por letra minúscula e podem conter dígitos e sublinhado depois. Não aceito maiúsculas, o que é uma restrição real da linguagem e não um descuido: reduz o alfabeto e me poupa de decidir se Email e email são o mesmo nome. Repare que a categoria casa também com as palavras reservadas — pattern é um identificador perfeitamente válido segundo essa expressão. Isso não é defeito: é a situação normal em qualquer linguagem, e o desempate por prioridade é assunto do módulo 7. Registrei o fato na observação para não parecer descuido quando alguém reler.

Números aceitam zeros à esquerda. Escrevi 007 no corpus de aceitação de propósito. Rejeitar zeros à esquerda exigiria uma expressão bem maior — algo como um dígito não zero seguido de dígitos, ou um zero isolado — e não traz ganho para esta linguagem. É uma decisão consciente de deixar a expressão simples, e ela está anotada.

Textos não têm sequências de escape. Essa é a decisão de escopo mais importante do módulo. Suportar \" dentro de um texto significa um pequeno analisador dentro do analisador léxico, com estado próprio. Cortei. A linguagem fica menos expressiva e o semestre fica viável.

O literal de padrão é reconhecido como bloco opaco. O conteúdo entre barras é a mini-expressão regular do usuário, mas o analisador léxico não olha para dentro dele — só reconhece que vai de barra a barra. Quem analisa o interior é o módulo 4. Separar as duas coisas é o que impede que o analisador léxico da linguagem e o analisador da notação de padrões virem um emaranhado só.

A pontuação inclui operadores de dois caracteres, e é isso que cria a necessidade do casamento mais longo. Anotei na observação: sem essa regra, >= seria lido como > seguido de =, e a condição where value(n) >= 100 seria analisada errado sem que nada acusasse. É o exemplo mais concreto que tenho para justificar, no módulo 7, uma regra que de outro modo pareceria detalhe.

Espaços são categoria de primeira classe, ainda que descartados. Estudantes costumam esquecê-los da especificação porque não viram símbolo. Mas é o espaço que informa onde um identificador termina, e ele precisa estar descrito.

1.2.1 Os corpora, e o que eles ainda não podem fazer

Cada categoria carrega cadeias que devem ser aceitas e cadeias que devem ser rejeitadas, com os casos de fronteira escolhidos deliberadamente por serem difíceis. Em números, incluí .5 e 5. entre as rejeitadas: as duas parecem números e nenhuma satisfaz a expressão, porque exijo pelo menos um dígito de cada lado do ponto. Incluí 1e10 porque notação científica é a extensão que todo mundo pede depois, e quero registrado que ela não faz parte da linguagem. Incluí +3 porque aceito sinal negativo e não positivo, o que é assimétrico e proposital.

E aqui está a limitação honesta desta entrega: os corpora ainda não são executáveis. Não existe reconhecedor. Eles são contrato, escrito antes da implementação e de propósito — no módulo 3 o primeiro reconhecedor aparece e passa a consumi-los, e no módulo 5 a ferramenta completa os verifica todos. Escrever o teste antes do código não é formalidade aqui: é o que me permitirá, daqui a três módulos, saber se o que construí está certo.

Onde é fácil errar aqui. Especificar por exemplo em vez de por expressão. É tentador escrever “números são coisas como 42 ou 3.14” e seguir em frente. Isso não é especificação, é ilustração, e desmonta no primeiro caso de fronteira. O teste que uso é perguntar de uma cadeia esquisita — -.5, digamos — se ela é aceita. Se eu preciso pensar, a expressão está imprecisa.

Como verificar. Rodando o subcomando que imprime a especificação, saem as seis categorias com notação, observação e os dois corpora. A saída é o documento de entrega, gerado a partir da mesma fonte que o compilador vai usar.

1.3 Referência teórica: operações sobre cadeias

O primeiro tópico do módulo é o vocabulário elementar — símbolo, alfabeto, cadeia — e as operações sobre cadeias. É material que parece simples demais para merecer código, e escrevi o código justamente porque ele não é.

02_cadeia.h
#ifndef PENEIRA_02_CADEIA_H
#define PENEIRA_02_CADEIA_H

#include <cstddef>
#include <string>
#include <vector>

namespace peneira {

// Uma cadeia é uma sequência finita de símbolos sobre um alfabeto. Usamos
// std::string como representação: os símbolos são caracteres, e a cadeia vazia
// é a string de comprimento zero.
using Cadeia = std::string;

// A cadeia vazia, que a teoria escreve como épsilon. Existe como função
// nomeada para que o código se leia como a definição formal.
const Cadeia& vazia();

Cadeia concatenar(const Cadeia& a, const Cadeia& b);

// Potência: a cadeia concatenada com ela mesma n vezes. Por definição,
// qualquer cadeia elevada a zero é a cadeia vazia — inclusive a própria
// cadeia vazia.
Cadeia potencia(const Cadeia& s, std::size_t n);

Cadeia reverso(const Cadeia& s);

// Prefixos, sufixos e subcadeias incluem a cadeia vazia e a própria cadeia,
// como manda a definição. Esquecer os extremos é o erro mais comum ao
// enumerá-los à mão.
std::vector<Cadeia> prefixos(const Cadeia& s);
std::vector<Cadeia> sufixos(const Cadeia& s);
std::vector<Cadeia> subcadeias(const Cadeia& s);

bool ehPrefixoDe(const Cadeia& possivel, const Cadeia& s);
bool ehSufixoDe(const Cadeia& possivel, const Cadeia& s);

}  // namespace peneira

#endif  // PENEIRA_02_CADEIA_H
02_cadeia.cpp
#include "02_cadeia.h"

#include <algorithm>
#include <set>

namespace peneira {

const Cadeia& vazia() {
    static const Cadeia epsilon;
    return epsilon;
}

Cadeia concatenar(const Cadeia& a, const Cadeia& b) { return a + b; }

Cadeia potencia(const Cadeia& s, std::size_t n) {
    Cadeia resultado;
    resultado.reserve(s.size() * n);
    for (std::size_t i = 0; i < n; ++i) {
        resultado += s;
    }
    return resultado;
}

Cadeia reverso(const Cadeia& s) {
    Cadeia resultado(s);
    std::reverse(resultado.begin(), resultado.end());
    return resultado;
}

std::vector<Cadeia> prefixos(const Cadeia& s) {
    std::vector<Cadeia> resultado;
    resultado.reserve(s.size() + 1);
    for (std::size_t i = 0; i <= s.size(); ++i) {
        resultado.push_back(s.substr(0, i));
    }
    return resultado;
}

std::vector<Cadeia> sufixos(const Cadeia& s) {
    std::vector<Cadeia> resultado;
    resultado.reserve(s.size() + 1);
    for (std::size_t i = 0; i <= s.size(); ++i) {
        resultado.push_back(s.substr(i));
    }
    return resultado;
}

std::vector<Cadeia> subcadeias(const Cadeia& s) {
    // Coletamos num conjunto para eliminar repetições: em "aaa" a subcadeia
    // "aa" aparece em duas posições e é uma só. O conjunto também deixa a
    // saída ordenada, o que torna a demonstração reproduzível.
    std::set<Cadeia> distintas;
    for (std::size_t inicio = 0; inicio <= s.size(); ++inicio) {
        for (std::size_t fim = inicio; fim <= s.size(); ++fim) {
            distintas.insert(s.substr(inicio, fim - inicio));
        }
    }
    return std::vector<Cadeia>(distintas.begin(), distintas.end());
}

bool ehPrefixoDe(const Cadeia& possivel, const Cadeia& s) {
    return possivel.size() <= s.size() &&
           s.compare(0, possivel.size(), possivel) == 0;
}

bool ehSufixoDe(const Cadeia& possivel, const Cadeia& s) {
    return possivel.size() <= s.size() &&
           s.compare(s.size() - possivel.size(), possivel.size(), possivel) == 0;
}

}  // namespace peneira

Antes das decisões de implementação, uma decisão de projeto que o código deixa implícita e convém explicitar: qual é o alfabeto da Peneira?

A teoria define cadeia como sequência finita de símbolos sobre um alfabeto, e o alfabeto precisa ser fixado antes de qualquer coisa. Adotei o conjunto de caracteres de um byte, e restringi na prática ao que as categorias léxicas usam — letras minúsculas, dígitos, sublinhado, os delimitadores e os sinais de pontuação. Represento cadeias com o tipo de texto da linguagem de implementação, o que significa que o alfabeto é o conjunto dos valores de um caractere.

A consequência dessa escolha aparece já aqui e cresce nos módulos seguintes. Um alfabeto de duzentos e cinquenta e seis símbolos torna a tabela de transição de um autômato larga, e é o que motivará, no módulo 3, representá-la por mapa esparso em vez de matriz densa. Se eu tivesse escolhido um alfabeto de texto multibyte, o número de símbolos deixaria de ser finito para efeitos práticos e a tabela de transição precisaria de outra estrutura inteira. É uma decisão pequena agora e cara de reverter depois, e por isso está tomada e anotada no módulo 2, não descoberta no módulo 5.

Duas decisões de implementação merecem explicação.

A cadeia vazia tem uma função nomeada em vez de aparecer como string literal vazia espalhada pelo código. Poderia ter escrito "" em toda parte e funcionaria. Nomeei porque o código passa a se ler como a definição formal, e porque a cadeia vazia é a fonte da maioria dos erros neste tópico — dar nome a ela força quem lê a notar que ela está ali.

Subcadeias são coletadas num conjunto ordenado. Em aaa, a subcadeia aa ocorre em duas posições e é uma só — conjunto não tem repetição. Enumerar por posição e devolver duplicatas é o erro clássico, e a estrutura de dados o impede por construção. O efeito colateral bom é que a saída sai ordenada e igual em toda execução, o que importa quando ela vai para um livro.

Rodando a demonstração sobre a cadeia abc, saem os quatro prefixos, os quatro sufixos e as sete subcadeias distintas. Repare que a cadeia vazia aparece em todas as três listas e que a própria abc aparece nas três também. São os dois extremos que somem quando alguém enumera à mão, e é por isso que a saída os mostra explicitamente.

1.4 Referência teórica: operações sobre linguagens

Subindo um nível: linguagens são conjuntos de cadeias, e as operações sobre elas são o próximo bloco do módulo.

02_linguagem.h
#ifndef PENEIRA_02_LINGUAGEM_H
#define PENEIRA_02_LINGUAGEM_H

#include <cstddef>
#include <set>
#include <string>

#include "02_cadeia.h"

namespace peneira {

// Uma linguagem é um conjunto de cadeias. Representamos com std::set por dois
// motivos: elimina repetições, como manda a definição de conjunto, e mantém
// ordem determinística, o que faz as demonstrações imprimirem sempre igual.
//
// Limitação assumida: só conseguimos representar linguagens FINITAS. As
// operações que produzem conjuntos infinitos (fecho) recebem um limite de
// comprimento e devolvem a fatia até ali. Isso é aproximação de demonstração,
// não implementação do conceito — o objeto infinito só ganha representação
// finita no módulo 3, quando o autômato entra.
using Linguagem = std::set<Cadeia>;

Linguagem uniao(const Linguagem& a, const Linguagem& b);
Linguagem intersecao(const Linguagem& a, const Linguagem& b);
Linguagem diferenca(const Linguagem& a, const Linguagem& b);

// Concatenação de linguagens: toda cadeia de `a` seguida de toda cadeia de
// `b`. O número de resultados é o produto dos tamanhos, e é por isso que o
// limite de comprimento existe.
Linguagem concatenacao(const Linguagem& a, const Linguagem& b,
                       std::size_t comprimentoMaximo);

Linguagem potencia(const Linguagem& a, std::size_t n,
                   std::size_t comprimentoMaximo);

// Fecho de Kleene: união de todas as potências, da zero em diante. Contém
// sempre a cadeia vazia — inclusive quando a linguagem de partida é vazia.
Linguagem fechoKleene(const Linguagem& a, std::size_t comprimentoMaximo);

// Fecho positivo: idem, mas a partir da potência um. Só contém a cadeia vazia
// se a linguagem de partida já a contiver.
Linguagem fechoPositivo(const Linguagem& a, std::size_t comprimentoMaximo);

// Formatação em notação de conjunto, com reticências quando há mais itens do
// que o limite pedido.
std::string formatar(const Linguagem& a, std::size_t maximoDeItens);

}  // namespace peneira

#endif  // PENEIRA_02_LINGUAGEM_H
02_linguagem.cpp
#include "02_linguagem.h"

#include <algorithm>
#include <iterator>
#include <sstream>

namespace peneira {

Linguagem uniao(const Linguagem& a, const Linguagem& b) {
    Linguagem resultado(a);
    resultado.insert(b.begin(), b.end());
    return resultado;
}

Linguagem intersecao(const Linguagem& a, const Linguagem& b) {
    Linguagem resultado;
    std::set_intersection(a.begin(), a.end(), b.begin(), b.end(),
                          std::inserter(resultado, resultado.end()));
    return resultado;
}

Linguagem diferenca(const Linguagem& a, const Linguagem& b) {
    Linguagem resultado;
    std::set_difference(a.begin(), a.end(), b.begin(), b.end(),
                        std::inserter(resultado, resultado.end()));
    return resultado;
}

Linguagem concatenacao(const Linguagem& a, const Linguagem& b,
                       std::size_t comprimentoMaximo) {
    Linguagem resultado;
    for (const Cadeia& x : a) {
        for (const Cadeia& y : b) {
            if (x.size() + y.size() <= comprimentoMaximo) {
                resultado.insert(x + y);
            }
        }
    }
    return resultado;
}

Linguagem potencia(const Linguagem& a, std::size_t n,
                   std::size_t comprimentoMaximo) {
    // Potência zero é o conjunto que contém apenas a cadeia vazia — não é o
    // conjunto vazio. Confundir os dois é o erro clássico deste tópico.
    Linguagem resultado{vazia()};
    for (std::size_t i = 0; i < n; ++i) {
        resultado = concatenacao(resultado, a, comprimentoMaximo);
    }
    return resultado;
}

Linguagem fechoKleene(const Linguagem& a, std::size_t comprimentoMaximo) {
    Linguagem resultado{vazia()};

    // Iteramos até o ponto fixo: enquanto concatenar com `a` acrescentar
    // cadeia nova dentro do limite, continuamos. O limite de comprimento é o
    // que garante a terminação — sem ele, o laço não pararia para qualquer
    // linguagem que contenha uma cadeia não vazia.
    for (;;) {
        const Linguagem novas = concatenacao(resultado, a, comprimentoMaximo);
        const std::size_t antes = resultado.size();
        resultado.insert(novas.begin(), novas.end());
        if (resultado.size() == antes) {
            break;
        }
    }
    return resultado;
}

Linguagem fechoPositivo(const Linguagem& a, std::size_t comprimentoMaximo) {
    return concatenacao(a, fechoKleene(a, comprimentoMaximo), comprimentoMaximo);
}

std::string formatar(const Linguagem& a, std::size_t maximoDeItens) {
    if (a.empty()) {
        return "{ }";
    }

    std::ostringstream saida;
    saida << "{ ";
    std::size_t escritos = 0;
    for (const Cadeia& c : a) {
        if (escritos == maximoDeItens) {
            saida << ", ... (" << a.size() - escritos << " a mais)";
            break;
        }
        if (escritos > 0) {
            saida << ", ";
        }
        saida << (c.empty() ? std::string("<vazia>") : c);
        ++escritos;
    }
    saida << " }";
    return saida.str();
}

}  // namespace peneira

O fecho de Kleene é onde a implementação encontra o limite do que é representável, e resolvi assim: itero concatenando com a linguagem de partida até que nada de novo apareça dentro do limite de comprimento — um cálculo de ponto fixo. O limite não é detalhe de implementação, é o que garante a terminação. Sem ele, o laço não pararia para nenhuma linguagem que contenha uma cadeia não vazia, porque sempre haveria uma cadeia mais longa a produzir.

A demonstração exibe de propósito os dois casos que contrariam a intuição da turma. O primeiro é a potência zero de uma linguagem, que vale o conjunto contendo a cadeia vazia, e não o conjunto vazio. O segundo é o fecho de Kleene do conjunto vazio, que também contém a cadeia vazia — enquanto o fecho positivo do conjunto vazio é, esse sim, vazio. Rodando, sai literalmente:

  fecho de Kleene do conjunto vazio = { <vazia> }   (contém a cadeia vazia)
  fecho positivo do conjunto vazio  = { }   (este sim é vazio)

Discutir isso no quadro convence menos que mostrar o programa imprimindo. A diferença entre a linguagem vazia e a linguagem que contém apenas a cadeia vazia é a distinção mais escorregadia do módulo, e ela reaparece com consequência prática no módulo 4, quando as transições vazias entrarem.

Onde é fácil errar aqui. Ao implementar a concatenação de linguagens, esquecer o filtro de comprimento e deixar o conjunto crescer sem controle. Com duas linguagens de dez cadeias cada, uma concatenação produz cem resultados, e dentro do laço do fecho isso multiplica rápido. O filtro precisa estar na concatenação, não depois dela.

1.5 Referência teórica: expressões regulares e sua semântica

O tópico central do módulo. Uma expressão regular é definida por indução: três casos-base e três construtores. Representei exatamente isso.

02_regex.h
#ifndef PENEIRA_02_REGEX_H
#define PENEIRA_02_REGEX_H

#include <cstddef>
#include <cstdint>
#include <memory>
#include <string>

#include "02_linguagem.h"

namespace peneira {

// Sintaxe abstrata de uma expressão regular, na definição indutiva da teoria:
// os três casos-base (linguagem vazia, cadeia vazia, símbolo) e os três
// construtores (união, concatenação, estrela). Tudo o mais é açúcar sobre
// esses seis.
enum class TipoRegex : std::uint8_t {
    Vazio,          // denota a linguagem { }
    Epsilon,        // denota a linguagem { cadeia vazia }
    Simbolo,        // denota a linguagem { "c" }
    Uniao,
    Concatenacao,
    Estrela,
};

struct Regex;

// Ponteiro compartilhado, e não exclusivo, porque as formas derivadas
// reaproveitam a mesma subárvore: `r+` é montado como `r` concatenado com
// `r*`, e o mesmo `r` aparece nos dois lados.
using RegexPtr = std::shared_ptr<const Regex>;

struct Regex {
    TipoRegex tipo;
    char simbolo;        // significativo quando tipo == Simbolo
    RegexPtr esquerda;   // subexpressão; nula nos casos-base
    RegexPtr direita;    // segunda subexpressão; nula fora de União/Concatenação
};

RegexPtr vazioRegex();
RegexPtr epsilonRegex();
RegexPtr simboloRegex(char c);
RegexPtr uniaoRegex(RegexPtr a, RegexPtr b);
RegexPtr concatRegex(RegexPtr a, RegexPtr b);
RegexPtr estrelaRegex(RegexPtr a);

// Formas derivadas, definidas em termos das seis primitivas.
RegexPtr maisUmRegex(RegexPtr a);     // r+  =  r r*
RegexPtr opcionalRegex(RegexPtr a);   // r?  =  r | epsilon

// Uma classe de caracteres é união de símbolos. Escrevemos assim para deixar
// explícito que `[a-z]` não é um construtor novo da teoria: é notação para
// vinte e seis uniões.
RegexPtr faixaRegex(char de, char ate);
RegexPtr conjuntoRegex(const std::string& simbolos);

// Notação textual da expressão, com parênteses apenas onde a precedência
// exige (estrela liga mais forte que concatenação, que liga mais forte que
// união).
std::string formatarRegex(const Regex& r);

// Semântica: a linguagem denotada pela expressão, calculada por indução sobre
// a estrutura — um caso para cada construtor, exatamente como a definição.
// Limitada por comprimento porque a linguagem pode ser infinita.
Linguagem linguagemDe(const Regex& r, std::size_t comprimentoMaximo);

// Compara duas expressões pelas linguagens que denotam, até o comprimento
// dado. Serve para exercitar as identidades algébricas.
//
// Atenção ao que isto é e ao que não é: concordar até um comprimento é
// evidência, não demonstração. Duas expressões podem coincidir até o
// comprimento dez e divergir no onze. A prova de equivalência vem no módulo 5,
// com a unicidade do autômato mínimo.
bool mesmaLinguagemAte(const Regex& a, const Regex& b,
                       std::size_t comprimentoMaximo);

}  // namespace peneira

#endif  // PENEIRA_02_REGEX_H
02_regex.cpp
#include "02_regex.h"

#include <sstream>
#include <utility>

namespace peneira {

namespace {

RegexPtr criar(TipoRegex tipo, char simbolo, RegexPtr esquerda, RegexPtr direita) {
    Regex r{tipo, simbolo, std::move(esquerda), std::move(direita)};
    return std::make_shared<const Regex>(std::move(r));
}

// Precedência para decidir parênteses: quanto maior, mais forte a ligação.
int precedencia(TipoRegex tipo) {
    switch (tipo) {
        case TipoRegex::Uniao:
            return 1;
        case TipoRegex::Concatenacao:
            return 2;
        case TipoRegex::Estrela:
            return 3;
        case TipoRegex::Vazio:
        case TipoRegex::Epsilon:
        case TipoRegex::Simbolo:
            return 4;
    }
    return 4;
}

std::string comParenteses(const Regex& filho, int precedenciaDoPai) {
    const std::string texto = formatarRegex(filho);
    if (precedencia(filho.tipo) < precedenciaDoPai) {
        return "(" + texto + ")";
    }
    return texto;
}

}  // namespace

RegexPtr vazioRegex() { return criar(TipoRegex::Vazio, '\0', nullptr, nullptr); }

RegexPtr epsilonRegex() {
    return criar(TipoRegex::Epsilon, '\0', nullptr, nullptr);
}

RegexPtr simboloRegex(char c) {
    return criar(TipoRegex::Simbolo, c, nullptr, nullptr);
}

RegexPtr uniaoRegex(RegexPtr a, RegexPtr b) {
    return criar(TipoRegex::Uniao, '\0', std::move(a), std::move(b));
}

RegexPtr concatRegex(RegexPtr a, RegexPtr b) {
    return criar(TipoRegex::Concatenacao, '\0', std::move(a), std::move(b));
}

RegexPtr estrelaRegex(RegexPtr a) {
    return criar(TipoRegex::Estrela, '\0', std::move(a), nullptr);
}

RegexPtr maisUmRegex(RegexPtr a) {
    // A mesma subárvore aparece duas vezes; é o que motiva o ponteiro
    // compartilhado em vez do exclusivo.
    RegexPtr estrela = estrelaRegex(a);
    return concatRegex(std::move(a), std::move(estrela));
}

RegexPtr opcionalRegex(RegexPtr a) {
    return uniaoRegex(std::move(a), epsilonRegex());
}

RegexPtr faixaRegex(char de, char ate) {
    RegexPtr resultado = simboloRegex(de);
    for (char c = static_cast<char>(de + 1); c <= ate; ++c) {
        resultado = uniaoRegex(std::move(resultado), simboloRegex(c));
    }
    return resultado;
}

RegexPtr conjuntoRegex(const std::string& simbolos) {
    if (simbolos.empty()) {
        return vazioRegex();
    }
    RegexPtr resultado = simboloRegex(simbolos[0]);
    for (std::size_t i = 1; i < simbolos.size(); ++i) {
        resultado = uniaoRegex(std::move(resultado), simboloRegex(simbolos[i]));
    }
    return resultado;
}

std::string formatarRegex(const Regex& r) {
    switch (r.tipo) {
        case TipoRegex::Vazio:
            return "0";
        case TipoRegex::Epsilon:
            return "e";
        case TipoRegex::Simbolo:
            return std::string(1, r.simbolo);
        case TipoRegex::Uniao:
            return comParenteses(*r.esquerda, precedencia(TipoRegex::Uniao)) +
                   "|" +
                   comParenteses(*r.direita, precedencia(TipoRegex::Uniao));
        case TipoRegex::Concatenacao:
            return comParenteses(*r.esquerda,
                                 precedencia(TipoRegex::Concatenacao)) +
                   comParenteses(*r.direita,
                                 precedencia(TipoRegex::Concatenacao));
        case TipoRegex::Estrela:
            return comParenteses(*r.esquerda, precedencia(TipoRegex::Estrela)) +
                   "*";
    }
    return std::string();
}

Linguagem linguagemDe(const Regex& r, std::size_t comprimentoMaximo) {
    // Um caso por construtor: a função é a definição indutiva transcrita.
    switch (r.tipo) {
        case TipoRegex::Vazio:
            return Linguagem{};
        case TipoRegex::Epsilon:
            return Linguagem{vazia()};
        case TipoRegex::Simbolo:
            return Linguagem{std::string(1, r.simbolo)};
        case TipoRegex::Uniao:
            return uniao(linguagemDe(*r.esquerda, comprimentoMaximo),
                         linguagemDe(*r.direita, comprimentoMaximo));
        case TipoRegex::Concatenacao:
            return concatenacao(linguagemDe(*r.esquerda, comprimentoMaximo),
                                linguagemDe(*r.direita, comprimentoMaximo),
                                comprimentoMaximo);
        case TipoRegex::Estrela:
            return fechoKleene(linguagemDe(*r.esquerda, comprimentoMaximo),
                               comprimentoMaximo);
    }
    return Linguagem{};
}

bool mesmaLinguagemAte(const Regex& a, const Regex& b,
                       std::size_t comprimentoMaximo) {
    return linguagemDe(a, comprimentoMaximo) ==
           linguagemDe(b, comprimentoMaximo);
}

}  // namespace peneira

Três pontos da implementação merecem comentário.

Só existem seis construtores. Linguagem vazia, cadeia vazia, símbolo, união, concatenação e estrela. Tudo o mais é açúcar: r+ é montado como r concatenado com r*, r? como r unido com a cadeia vazia, e uma classe como [a-z] como vinte e seis uniões. Escrever as formas derivadas em termos das primitivas, e não como casos novos do enumerado, é a decisão que mantém honesta a correspondência com a teoria — e ela se paga no módulo 4, quando a construção de Thompson precisar tratar apenas seis casos em vez de doze.

O ponteiro é compartilhado, e não exclusivo. Quando monto r+ como r seguido de r*, a mesma subárvore aparece nos dois lados. Com ponteiro exclusivo eu precisaria duplicar a árvore; com compartilhado, aponto duas vezes para a mesma. É uma das poucas situações em que o compartilhamento é a escolha certa por razão estrutural, e não por conveniência.

A função que calcula a linguagem denotada é a definição transcrita. Um caso para cada construtor, cada um devolvendo exatamente o que a definição diz: o conjunto vazio, o conjunto com a cadeia vazia, o conjunto com um símbolo, a união, a concatenação, o fecho. Quem ler essa função lado a lado com a definição do livro vai encontrar a mesma coisa em duas notações. Era esse o objetivo.

A demonstração exercita as identidades algébricas comparando as linguagens geradas. Rodando, sai:

  (a|b)* == (a*b*)* : confere
  a** == a*        : confere
  ab == ba         : diverge   (esperado: diverge)

O terceiro caso está ali de propósito. Uma bateria de verificações em que tudo passa não prova que a verificação funciona — pode ser que ela sempre responda “confere”. Incluir um caso que deve falhar é o que dá confiança nos outros dois.

A ressalva que precisa ser dita em voz alta. Concordar até um comprimento dado é evidência, não demonstração. Duas expressões podem denotar linguagens que coincidem até o comprimento dez e divergem no onze, e este verificador diria “confere”. Deixei isso escrito no comentário do cabeçalho e impresso na saída da demonstração, porque é exatamente o tipo de conclusão apressada que um programa que funciona induz. A decisão de equivalência de verdade chega no módulo 5, com a unicidade do autômato mínimo — e é um dos motivos pelos quais aquele resultado importa.

Onde é fácil errar aqui. Ao formatar a expressão de volta para texto, esquecer os parênteses de precedência. Sem eles, a união de a com b, tudo sob estrela, imprimiria como a|b*, que é outra expressão. Resolvi com uma tabela de precedência e parentização condicional: o filho só ganha parênteses quando liga mais fraco que o pai. Verifiquei que (a|b)* imprime com os parênteses e que aa* imprime sem, que é o comportamento correto nos dois sentidos.

1.6 Tópicos deste módulo sem código de referência

Um tópico ficou sem implementação, e registro por quê.

A distinção entre a notação teórica e as notações das bibliotecas de programação é conceitual. As construções que as bibliotecas oferecem além da teoria — retrovisores, âncoras, olhares adiante — não são regulares, e implementá-las aqui seria implementar exatamente aquilo que a disciplina quer que o estudante reconheça como fora da classe. O que o código faz é o oposto e é mais útil: por só oferecer os seis construtores, ele torna a fronteira palpável. Quando alguém perguntar por que não dá para escrever um retrovisor com estas peças, a resposta está na ausência delas.

1.7 Verificação da entrega

Item Como conferir Estado nesta referência
Categorias léxicas identificadas Toda “palavra” do programa de exemplo cai em alguma categoria Seis categorias, cobrindo o exemplo do módulo 1
Notação precisa Cadeia de fronteira é classificável sem hesitação Atende; casos difíceis nos corpora
Corpus de aceitação Casos típicos e de fronteira, por categoria Entre três e seis por categoria
Corpus de rejeição Casos que quase passam Entre dois e sete por categoria
Decisões registradas Cada escolha de escopo tem justificativa escrita Uma observação por categoria
Código compila limpo Sem nenhum aviso sob o modo estrito Atende, verificado por compilação

O que quero deixar registrado sobre esta entrega é que a parte avaliada — a especificação — não tem código, e que o código que a acompanha existe para tornar a teoria conferível, não para adiantar o compilador. Nenhuma linha escrita neste módulo faz parte do analisador léxico. Grupos que tentarem começar o analisador agora estarão implementando sobre uma teoria que ainda não estudaram: o autômato chega no módulo 3, e a construção que transforma expressão em autômato, no módulo 4.