Funções Anônimas e Capturas — Lambdas de Verdade

Funções Anônimas e Capturas — Lambdas de Verdade

O que distingue uma lambda de uma função comum é a captura: ela leva consigo o ambiente onde foi escrita. Por valor congela uma cópia, por referência enxerga o original — e é aí que mora o risco, porque a referência não prolonga a vida de nada e a falha só aparece quando a lambda é chamada.
Linguagem C++

11 min de leitura

Fechamos a Fase 4 e, ao longo dela, usei dezenas de vezes uma construção que sempre prometi explicar: aquelas funções entre colchetes, [](int x){ return x*2; }, que passávamos aos algoritmos. Chamei-as de lambdas e pedi paciência. A paciência acaba agora. Abrimos a Fase 5 — a da genericidade, onde o código deixa de trabalhar com um tipo e passa a trabalhar com qualquer tipo — e as lambdas são a porta de entrada perfeita, porque você já as viu funcionar. Hoje você entende o que elas realmente são: funções escritas no local, sem nome, capazes de "capturar" variáveis do ambiente ao redor. Esse último poder — a captura — é o que as distingue de uma função comum e o que as torna indispensáveis. Ao fim da aula, aquele colchete misterioso terá revelado todos os seus segredos.

O que é uma lambda: uma função sem nome, escrita onde é usada

Em C, quando um algoritmo precisava de uma função (como o comparador do qsort), você era obrigado a defini-la em outro lugar, dar-lhe um nome, e passar um ponteiro para ela — mesmo que ela fosse usada uma única vez. A lambda elimina essa cerimônia: você escreve a função no exato ponto onde ela é necessária:

#include <iostream>

int main() {
    // Uma lambda atribuída a uma variável, para vermos sua anatomia.
    auto dobro = [](int x) { return x * 2; };
    //           ^^        ^^^^^^^         ^^^^^^^^^^^^^^
    //           captura   parâmetros      corpo

    std::cout << dobro(5) << '\n';   // 10 — chama-se como uma função normal
    std::cout << dobro(21) << '\n';  // 42
    return 0;
}

A anatomia tem três partes. Os colchetes [] são a lista de captura (por ora vazia — já chegamos nela). Os parênteses (int x) são os parâmetros, como em qualquer função. As chaves { ... } são o corpo. O tipo de retorno é deduzido automaticamente (aqui, int), embora você possa especificá-lo com -> tipo quando precisar. Guardamos a lambda em auto porque seu tipo é gerado pelo compilador e não tem nome que você possa escrever — é o caso de uso do auto do artigo Adeus NULL, Bem-vindo à Dedução — nullptr e auto em sua forma mais pura.

A captura: o superpoder que uma função comum não tem

Até aqui, uma lambda parece só uma função escrita de forma compacta. O que a torna especial é a captura: a capacidade de acessar variáveis que existem no escopo onde ela foi definida. Aqueles colchetes [] são onde você diz quais variáveis do ambiente a lambda pode usar:

#include <iostream>
#include <vector>
#include <algorithm>

int main() {
    int limite = 10;

    std::vector<int> v = {5, 12, 8, 20, 3};

    // A lambda CAPTURA 'limite' por valor (o [limite] nos colchetes).
    // Agora ela "carrega" uma cópia de limite e pode usá-lo lá dentro.
    int acima = std::count_if(v.begin(), v.end(),
                              [limite](int x) { return x > limite; });
    //                         ^^^^^^^ captura: a lambda enxerga 'limite'

    std::cout << acima << " elementos acima de " << limite << '\n';   // 2
    return 0;
}

Sem a captura, a lambda não saberia o que é limite — ele é uma variável local do main, não um parâmetro da lambda. Ao escrever [limite], você diz "leve uma cópia de limite com você". Isso é o que uma função comum não pode fazer: uma função nomeada só conhece seus parâmetros e variáveis globais; uma lambda pode "fotografar" o estado local ao seu redor. Uma lambda com captura é tecnicamente chamada de closure (fechamento), porque ela "fecha sobre" as variáveis que captura.

Capturar por valor versus por referência

Há duas formas de capturar, e a distinção é a mesma de passar argumentos (artigo Referências e Ponteiros, Frente a Frente), com as mesmas consequências. Capturar por valor ([x]) leva uma cópia — mudanças externas posteriores não afetam a lambda, e a lambda não afeta o original. Capturar por referência ([&x]) leva um vínculo ao original — a lambda vê mudanças e pode modificá-lo:

#include <iostream>

int main() {
    int contador = 0;

    // Captura POR VALOR: a lambda tem sua própria cópia congelada.
    auto por_valor = [contador]() { return contador; };

    // Captura POR REFERÊNCIA: a lambda acessa o 'contador' real.
    auto incrementa = [&contador]() { ++contador; };

    contador = 100;        // muda o original DEPOIS de criar as lambdas
    std::cout << por_valor() << '\n';   // 0  — capturou a cópia de quando valia 0
    incrementa();          // mexe no contador real
    incrementa();
    std::cout << contador << '\n';      // 102 — a referência viu e alterou
    return 0;
}

[contador] fotografou o valor 0 no momento da criação e o congelou; mudar contador depois não afeta a cópia dentro da lambda. [&contador] vinculou-se ao original, então incrementa() de fato altera o contador do main. Existem também as formas de captura padrão: [=] captura tudo o que for usado por valor, e [&] captura tudo por referência. Elas são convenientes, mas serei honesto sobre o risco no próximo bloco.

A armadilha honesta: captura por referência e tempo de vida

Prometo sempre o perigo, e o da captura por referência é sério porque é o dangling de novo, agora disfarçado. Uma lambda que captura por referência guarda um vínculo a uma variável; se essa variável morrer antes de a lambda ser chamada, o vínculo vira uma referência pendente. Isso é especialmente traiçoeiro quando você retorna uma lambda de uma função:

#include <functional>

// PERIGO: a lambda captura 'x' por referência, mas 'x' é local a criar_bug.
std::function<int()> criar_bug() {
    int x = 42;
    return [&x]() { return x; };   // x morre ao retornar; a referência fica pendente
}
// Chamar o resultado de criar_bug() lê memória inválida — comportamento indefinido.

A regra de bolso: capture por referência ([&x] ou [&]) apenas quando a lambda for usada imediatamente e localmente, dentro do mesmo escopo — como nos algoritmos, onde a lambda é consumida na hora. Se a lambda vai sobreviver ao escopo atual (ser guardada, retornada, usada depois), capture por valor ([x] ou [=]), para que ela leve suas próprias cópias e não dependa de variáveis que podem morrer. É a distinção posse-versus-observação da Fase 2 reaparecendo nas capturas: por valor, a lambda possui seus dados; por referência, ela apenas observa — e observar algo morto é fatal.

A captura é o que separa a lambda de uma função comum: ela leva consigo o ambiente em que foi escrita. Por valor, congela uma cópia no momento da criação; por referência, enxerga o original e reflete mudanças posteriores — e essa é a forma que cobra atenção, porque a referência não prolonga a vida de nada. Uma lambda que captura por referência e sobrevive ao escopo onde nasceu guarda referências penduradas, e o problema só aparece quando ela é finalmente chamada.

Fontes e leituras recomendadas

  • cppreference.com/w/cpp/language/lambda: a referência completa sobre lambdas, listas de captura, o especificador mutable e a dedução do tipo de retorno.
  • Bjarne Stroustrup, A Tour of C++ (3ª ed.), seções sobre lambdas: a apresentação das funções anônimas pelo criador da linguagem.
  • Scott Meyers, Effective Modern C++ (2014), Itens 31–34 (sobre lambdas e capturas): a discussão definitiva dos perigos de captura por referência e por valor.
  • cppreference.com/w/cpp/utility/functional/function: a referência de std::function, o tipo que guarda qualquer "coisa chamável", incluindo lambdas.
  • ISO C++ Core Guidelines, regras F.50 ("Use a lambda when a function won't do") e F.52/F.53 (capturas por referência vs valor): as diretrizes sobre quando e como usar lambdas.

Exercícios

Exercício 1

Escreva uma lambda que receba dois int e devolva o maior deles. Atribua-a a uma variável auto e demonstre chamando-a com alguns pares.

Ver resposta

✓ Resposta: A lambda do máximo:

#include <iostream>
int main() {
    auto maior = [](int a, int b) { return a > b ? a : b; };
    std::cout << maior(3, 7) << '\n';    // 7
    std::cout << maior(10, 2) << '\n';   // 10
    return 0;
}

O tipo de retorno (int) é deduzido automaticamente do corpo.

Exercício 2

Dado um std::vector<int> e um valor int minimo lido do usuário, use std::count_if com uma lambda que capture minimo para contar quantos elementos são maiores ou iguais a ele.

Ver resposta

✓ Resposta: Contagem com captura:

#include <iostream>
#include <vector>
#include <algorithm>
int main() {
    std::vector<int> v = {5, 12, 8, 20, 3};
    int minimo;
    std::cout << "mínimo: ";
    std::cin >> minimo;
    int quantos = std::count_if(v.begin(), v.end(),
                                [minimo](int x) { return x >= minimo; });
    std::cout << quantos << " elementos >= " << minimo << '\n';
    return 0;
}

A lambda captura minimo por valor para poder compará-lo com cada elemento — sem a captura, minimo seria desconhecido dentro dela.

Exercício 3

Explique a diferença de comportamento entre [x] e [&x] numa lambda, usando um exemplo em que modificar x após a criação da lambda produz resultados diferentes nas duas formas.

Ver resposta

✓ Resposta: [x] captura uma cópia de x no momento da criação da lambda, congelando-a; [&x] captura uma referência ao x original, que reflete mudanças posteriores. Exemplo:

#include <iostream>
int main() {
    int x = 1;
    auto copia = [x]()  { return x; };   // fotografa x = 1
    auto ref   = [&x]() { return x; };   // vincula-se ao x real
    x = 99;                               // muda x DEPOIS
    std::cout << copia() << '\n';   // 1  — a cópia não vê a mudança
    std::cout << ref()   << '\n';   // 99 — a referência vê
    return 0;
}

copia() devolve 1 porque congelou o valor original; ref() devolve 99 porque acessa o x atual. É a mesma distinção de valor versus referência que rege parâmetros de função.

Exercício 4

O código abaixo tem um bug de tempo de vida com captura por referência. Aponte-o e corrija trocando a estratégia de captura, explicando por que a correção resolve.

#include <functional>
#include <vector>
std::vector<std::function<int()>> fabricas;
void registra(int n) {
    fabricas.push_back([&n]() { return n * 10; });   // guarda a lambda para depois
}
Ver resposta

✓ Resposta: O bug: [&n] captura o parâmetro n por referência, mas n é local a registra e morre quando a função retorna. As lambdas guardadas em fabricas ficam com referências pendentes; chamá-las depois lê memória inválida. Correção: capturar por valor, para que cada lambda leve sua própria cópia de n:

#include <functional>
#include <vector>
std::vector<std::function<int()>> fabricas;
void registra(int n) {
    fabricas.push_back([n]() { return n * 10; });   // captura por valor: cópia própria
}

A correção resolve porque, com [n], cada lambda possui sua própria cópia de n, independente da variável local que morre — a lambda não depende mais de nada que possa deixar de existir. É a regra da aula: lambdas que sobrevivem ao escopo devem capturar por valor.

Exercício 5

Use uma lambda com captura por referência para somar os elementos de um std::vector<int> num acumulador externo, percorrendo com std::for_each. Depois explique por que, neste caso específico, a captura por referência é segura, ligando à regra de tempo de vida da aula.

Ver resposta

✓ Resposta: Soma com captura por referência:

#include <iostream>
#include <vector>
#include <algorithm>
int main() {
    std::vector<int> v = {1, 2, 3, 4};
    int soma = 0;
    // for_each aplica a lambda a cada elemento; ela acumula em 'soma' por referência.
    std::for_each(v.begin(), v.end(), [&soma](int x) { soma += x; });
    std::cout << "soma: " << soma << '\n';   // 10
    return 0;
}

A captura por referência é segura aqui porque a lambda é usada imediatamente e localmente: std::for_each a executa na mesma hora, dentro do mesmo escopo onde soma ainda está viva, e a lambda não sobrevive à chamada nem é guardada em lugar nenhum. Como soma existe durante todo o percurso e a lambda é descartada logo após, não há risco de referência pendente — exatamente o caso em que a regra da aula permite a captura por referência. (Na prática, std::accumulate seria mais idiomático para somar, mas o exemplo ilustra a captura segura por referência.)

Comentários

Mais em Linguagem C++

Escreva uma Vez, Use com Todo Tipo — Templates de Função
Escreva uma Vez, Use com Todo Tipo — Templates de Função

Escrever a mesma função para int, double e string é o tipo de repetição que o…

Uma Classe que Nasce de Outra — Herança e a Cadeia de Construção
Uma Classe que Nasce de Outra — Herança e a Cadeia de Construção

Fazer uma classe nascer de outra é a parte fácil; saber quando não fazer é o…

Buscas Instantâneas — map, set e suas Versões Hash
Buscas Instantâneas — map, set e suas Versões Hash

Nem todo problema é sobre sequências; muitos são sobre associações, e em C…