Em meu primeiro post aqui no blog apresentei um problema que eu encontrei ao tentar encarar as funções lambda de C++ exatamente como os lambdas de outras linguagens modernas. Naquele momento, após algumas tentativas frustradas, me convenci que lambdas não eram nada mais do que syntatic sugar para declarações de funções localmente, apenas para dar maior contexto ao código. Pouco tempo após o post ir ao ar, o colega Paulo Pires mostrou que aquilo que eu queria era possível, o que lhe custou algumas horas de sono. Prometo ao Paulo que seguirei o conselho de RTFM e tentarei não lhe causar mais insônias.
O que é uma função lambda, afinal?
![]() |
Acredite, uma coisa não tem nada a ver com a outra. |
Funções lambda são elementos matemáticos teóricos que surgiram com a noção de cálculo lambda. Este modelo sugeria que qualquer coisa pode ser calculada a partir da definição de funções, utilizando as chamadas expressões lambda. Tais funções não precisam sequer ter nome, e podem conter chamadas a si próprias em sua definição, no processo determinado como recursão.
O contexto das funções definidas por uma expressão lambda é denominado closure. Este elemento, que determina não apenas as operações que serão realizadas sobre o alvo da função, como também todos os outros elementos ao qual a função tem acesso, é que é utilizado pela linguagem e tratado como um objeto de primeira classe. Então, muitas vezes, quando nos referimos a uma função lambda, na verdade estamos nos referindo à closure que a define.
O contexto das funções definidas por uma expressão lambda é denominado closure. Este elemento, que determina não apenas as operações que serão realizadas sobre o alvo da função, como também todos os outros elementos ao qual a função tem acesso, é que é utilizado pela linguagem e tratado como um objeto de primeira classe. Então, muitas vezes, quando nos referimos a uma função lambda, na verdade estamos nos referindo à closure que a define.
Este modelo formal é a base das chamadas linguagens funcionais, que utilizam funções declaradas através de expressões lambda para realizar todo trabalho computacional do problema que o programador quer modelar. Pertencem a essa categoria linguagens como Lisp, Scheme, Haskell e F#.
Apesar de serem muito populares entre matemáticos e físicos, linguagens funcionais nunca "decolaram" fora do ambiente acadêmico. Talvez em muito porque os programadores são expostos tarde demais a elas. Como eles já tem o vício de pensar e modelar seus programas em termos de linguagens estruturadas, ou orientadas a objetos, voltar ao básico e reaprender a pensar de forma funcional torna a curva de aprendizado destas linguagens bem maior.
Ainda assim, a ideia de tratar funções como objetos de primeira classe, ou seja, como elementos básicos da linguagem que podem ser manipulados livremente, é extremamente útil e encontrou lugar em várias linguagens modernas. Com uma visão mais pragmática, estas linguagens mesclam vários paradigmas de programação, incluindo a programação funcional. Entre elas, temos Python, Ruby, Scala e, agora, C++.
Nestas linguagens, funções definidas por expressões lambda são comumente utilizadas para aplicar um conjunto de operações sobre um conjunto determinado de dados, como imprimir o valor do dobro de cada componente de um vetor. Enquanto isso, tarefas que tem um escopo de projeto, que definem o modelo do software, e cujo nome é usado para identificar o fluxo de trabalho, continuam escritas em funções ou métodos "puros".
Nestas linguagens, funções definidas por expressões lambda são comumente utilizadas para aplicar um conjunto de operações sobre um conjunto determinado de dados, como imprimir o valor do dobro de cada componente de um vetor. Enquanto isso, tarefas que tem um escopo de projeto, que definem o modelo do software, e cujo nome é usado para identificar o fluxo de trabalho, continuam escritas em funções ou métodos "puros".
Funções lambda em C++
Transformar funções em objetos de primeira classe em C++ exigiu algumas mudanças semânticas na linguagem. Segundo o padrão, uma closure definida por uma expressão lambda é um tipo de dados único, próprio e sem nome. Mesmo que duas expressões possuam os mesmos argumentos e tipo de retorno, elas definem tipos distintos. Ao usuário comum, está disponível o tipo std::function, definido no cabeçalho functional da biblioteca padrão, como um wrapper sobre tipo criado.
Curiosamente, std::function não é definido sobre o tipo da closure em si, mas sobre o tipo do valor de retorno e argumentos da função que a definiu. Assim, mesmo que uma expressão lambda tenha sido atribuída a uma variável, esta última poderá apontar para outra closure desde que as duas funções possuam os mesmos argumentos e tipo de retorno.
auto a = [] { return 1 + 1; }; a = [] { return 2 + 2; }; // Ok, a é do tipo std::function<int()> a = [](int x) { return x + x; }; // Erro, a closure tem uma função // de tipo int (int)
Os tipos de cada closure, obrigatoriamente, devem possuir conversões para ponteiros de função com os mesmos tipos declarados na expressão lambda. Assim, mesmo que você possua uma função, ou método, escrito em uma versão anterior ao padrão C++11 que tome como argumento um ponteiro para uma função, você pode usar uma expressão lambda para definir esta função, in-place.
/* Código C89 válido */ int sum_then_callback(int x, int y, (int *)callback_func(int)) { return callback_func(x+y); } // Código C++11 válido int a = sum_then_callback(1, 2, [](int x){ return 4 * x;});
Sintaxe de expressões lambda
A expressão lambda que define uma closure é uma expressão comum, que pode ser utilizada em todos os contextos nos quais uma outra expressão qualquer é utilizada em C++, como soma ou subtração. Uma expressão lambda tem a seguinte sintaxe:[captura] (lista-de-argumentos) mutable -> tipo-de-retorno { bloco-de-código }
Com exceção da instrução de captura e do bloco de código que define a função, todo o resto é opcional, inclusive o tipo de retorno. Se sua lambda não vai tomar nenhum argumento, tanto a lista de argumentos quanto os parênteses que a envolvem podem ser omitidos da expressão. Se ela não irá modificar nenhum elemento em escopo externo, a palavra-chave mutable pode ser omitida. O tipo de retorno é completamente opcional, e, se for omitido, o compilador irá inferi-lo automaticamente.
Talvez o elemento que mais mereça cuidado, além de mutable, que vou explicar mais tarde, é a captura. Apesar dos colchetes serem obrigatórios, a captura pode ficar completamente vazia. Sua função é especificar que variáveis externas ao bloco de código da expressão lambda farão parte da closure, e de que forma.
Dentro da captura, você pode especificar exatamente quais variáveis farão parte da closure e se a lambda irá acessá-las por cópia ou referência. Caso você vá capturar a maioria das variáveis de uma forma ou de outra, pode usar o simbolos = (para capturar por cópia) ou & (para capturar por referência). Nesta situação, o compilador irá analisar quais variáveis externas à closure são utilizadas no bloco de código da expressão lambda e capturá-las da forma solicitada.
Como expressões lambda podem ser utilizadas como qualquer outra expressão em C++, inclusive para inicializar variáveis, acabamos ganhando de bônus uma outra sintaxe para a definição de funções. Se você vem de alguma linguagem de scripting, provavelmente vai adorar. Caso contrário, como eu, sofrerá calafrios na espinha.
#include <iostream> auto printdouble = [] (double x) { std::cout << x << std::endl; }; int main() { printdouble(2.0); }
Como a closure atribuida a printdouble não captura nada, o compilador irá, efetivamente, criar uma função regular e atribuí-la a printdouble. Apesar desta sintaxe gerar uma função regular, seu acesso é feito através de um ponteiro para função. Note no entanto, que, justamente por isso, a mesma técnica não pode ser aplicada à função main, uma vez que a função gerada pela closure não possui nome. Isso acaba gerando até mesmo uma inconsistência no comportamento de expressões lambda. Não acho difícil que a sintaxe seja revisada na próxima versão do padrão para permitir que main seja declarada através de uma closure, desde que também possua um modificador const na declaração.
Por dentro do closure type e o papel de mutable
Você se lembra que eu comecei este post falando que as expressões lambda eram apenas um syntatic sugar? Pois é, elas são isso mesmo, também são muito poderosas e versáteis.
Cabe ao compilador determinar como ele vai representar um closure type declarado por uma expressão lambda. No caso em que a expressão não faz nenhuma captura, é muito provável que o closure type, no final, seja reduzido a uma função regular. Apenas é feita a cópia do bloco de código da expressão para uma outra parte do fonte, declarando uma função com um nome único (compiladores são muito bons em dar nomes a coisas anônimas) e atribuir o endereço desta função a um ponteiro. Como o padrão determina que esta função deve ser inline, é possível até que nenhuma função seja criada, apenas copiando o bloco de código e montando o quebra-cabeças de chamadas como se fossem macros (copiar e montar blocos de código como quebra-cabeça é outra coisa em que compiladores são muito bons).
Mas e no caso de uma closure que faça captura de variáveis? Bom, neste caso a closure será reduzida a um functor.
Para explicar melhor o que acontece, vou voltar ao exemplo do meu post anterior: uma função que calcula o próximo número da sequência de Fibonacci a cada chamada. No fim, acabei recorrendo a um functor. O que eu não sabia é que o mesmo functor poderia ser gerado pela expressão lambda.
A closure que eu gerei ao tentar passar objetos por referência foi a seguinte:
function<int()> fib() { int n1 = 1, n2 = 1; return [&] () { int temp = n1; n1 = n2; n2 += temp; return temp; }; }
Note que a expressão lambda captura n1 e n2 por referência. Como há captura de variáveis, o compilador gerará um closure type que é um functor. Ao final, o que a sintaxe vai gerar é a seguinte construção:
class FunctorObj01: public function_interface<(int*)()> { int &n1_, &n2_; public: FunctorObj01(int& n1, int& n2): n1_(n1), n2_(n2) { } inline int operator()() const { int temp = n1_; n1_ = n2_; n2_ += temp; return temp; } }; function<int()> fib() { int n1 = 1, n2 = 1; return FunctorObj01(n1, n2); }
function_interface<T> seria uma interface interna do compilador que geraria todos os operadores de conversão do functor para ponteiros de função com os mesmos argumentos de operator().
Analisando friamente, você observa que foi exatamente isso que aconteceu. O meu functor trabalhou em cima de referências a variáveis locais. Assim, quando elas saíram de escopo, o comportamento do código se tornou indefinido. Repare também que o código da closure ficou dentro de operator(), declarado como inline e const. Isso significa que ele não pode modificar nenhuma variável da closure, e de fato, não modificamos as referências a n1 e n2. Mesmo modificando o conteúdo para o qual as referências apontam, a referência se mantém constante e, portanto, não há modificação do contexto da closure.
A solução que o Paulo encontrou, e publicou nos comentários, foi a seguinte construção:
function<int()> fib() { int n1 = 1, n2 = 1; return [=] () mutable { int temp = n1; n1 = n2; n2 += temp; return temp; }; }
Note que, desta vez estamos capturando n1 e n2 por cópia, além de adicionar o qualificador mutable, que nos permite modificar os valores dentro da closure. O compilador, após encontrar o código acima, vai gerar o seguinte functor.
class FunctorObj01: public function_interface<(int*)()> { int n1_, n2_; public: FunctorObj01(int n1, int n2): n1_(n1), n2_(n2) { } inline int operator()() { int temp = n1_; n1_ = n2_; n2_ += temp; return temp; } }; function<int()> fib() { int n1 = 1, n2 = 1; return FunctorObj01(n1, n2); }
Note a ausência de const e a transformação de n1 e n2 como variáveis privadas dentro do functor. Como ambas existem fora do operator(), elas mantém seus valores entre as chamadas. Como existem dentro de um objeto, possuem áreas de memória separadas de outras instâncias. A presença da palavra-chave mutable alterou o contexto da closure e removeu o qualificador const da definição do operator(). Afinal, o functor gerado pelo compilador usando expressões lambda tem o mesmo efeito do functor que criei "na unha" no post anterior.
Conclusão
Sim, ainda é C++. No final, ainda teremos functors, ponteiros, e funções de nível mais baixo. Mas o efeito de utilizar expressões lambda para construí-los é simplesmente libertador. É incrível observar como os membros do comitê de padronização conseguiram adicionar um conceito tão alienígena e de alto nível a C++ mantendo a mesma flexibilidade e poder de expressão da linguagem.
Também é surpreendente ver como estes elementos podem ser utilizados mesmo em cima de código legado, que não tem qualquer conhecimento sobre lambdas, closures ou functors. Sem dúvida nenhuma, este é um trabalho de primeiro nível dos profissionais envolvidos com o comitê.
Lambdas podem até não fazer mágica, mas o comitê de padronização ISO-C++ fez.