quinta-feira, 19 de abril de 2012

Funções lambda não fazem mágica

Muitos se encantaram com a inclusão de lambdas à linguagem C++ no novo padrão aprovado no ano passado. Tanto é que as versões mais novas dos principais compiladores já suportam a nova sintaxe. Mas vale lembrar que algumas regras básicas da linguagem continuam valendo, e você deve levar isso em consideração ao criar seus lambdas.

[Nota: conforme apresentado pelo Paulo Pires nos comentários, me convenci de que lambdas, afinal, fazem mágica. O post corrigindo as informações apresentadas está aqui.]

Meu experimento começou há pouco mais de uma semana. Estava estudando a linguagem Go, criada pelo Google, e um trecho de código em especial chamou minha atenção: uma função que retornava o próximo número da sequência de Fibonacci a cada chamada.

func fib() func() int {
    n1 := 0
    n2 := 1

    return func() int {
        temp := n1 + n2
        n1 = n2
        n2 = temp
        return temp
    }
}

func main() {
    f := fib()

    // Imprime os dez primeiros numeros 
    // da sequencia de Fibonacci
    for i:=0; i<10; i++ {
        fmt.Println( f() )
    }
}
Resultado:
1
2
3
5
8
13
21
34
55
89

Eu fiquei bem empolgado com a engenhosidade de Go e resolvi brincar com lambdas também em C++. A princípio, tentei uma tradução quase literal do código acima, tomando cuidado apenas para capturar n1 e n2 por referência, já que são variáveis que minha função deveria modificar.

#include <functional>

std::function<int()> fib() {
    int n1 = 0, n2 = 1;
   
    return [&] {
        int temp = n1 + n2;
        n1 = n2; n2 = temp;
        return temp;
    }
}
Resultado:
1
11
20
20
20
20
20
20
20
20
Mas, que diabos! Não era isso que o código deveria gerar! O que está acontecendo?

Este é um comportamento que está explicado no padrão C++11. Se uma expressão lambda captura variáveis por referência e estas variáveis saem de escopo (portanto, "deixam de existir") antes do código ser chamado, o resultado disso incorre em comportamento indefinido. Qualquer coisa pode sair daí, desde o resultado correto até o apocalipse zumbi.

Zumbis - o undefined behavior da natureza.

No nosso caso não ocorre nada tão drástico, mas ainda assim imprevisível. Ambas n1 e n2 são variáveis locais e, apesar de serem referenciadas pela função lambda, elas não são transferidas para o seu escopo. Assim, quando nosso código sai de fib, ambas deixam de existir e  nossa pequena lambda está utilizando áreas de memória com valores completamente aleatórios para realizar o cálculo da sequência de Fibonacci.

Tentando consertar a minha trapalhada, modifiquei a declaração das variáveis para que ambas tivessem escopo estático, sendo, portanto, mantidas após o fim da função na qual foram declaradas. Rodei o código e... Sucesso! A sequência de Fibonacci foi exibida corretamente! Então está tudo correto, certo?



Declarar n1 e n2 como variáveis estáticas resolveu um problema, mas introduziu outro. Para que você tenha uma noção melhor de por quê ainda não temos nada igual a Go, basta uma mudança simples abaixo.

func main () {
    f := fib()
    g := fib()

    for i := 0; i < 10; i++ {
        fmt.Printf("%d\t%d\n", f(), g())
    }
}
Resultado:
1    1
2    2
3    3
5    5
8    8
13    13
21    21
34    34
55    55
89    89
O código equivalente em C++ retorna algo bem diferente:
2    1
5    3
13    8
34    21
89    55
233    144
610    377
1597    987
4181    2584
10946    6765
Isso é consequência direta da nossa "solução". Como n1 e n2 são variáveis estáticas, não importa quantas "funções" nós retornemos através de fib, todas irão apontar para as mesmas variáveis n1 e n2.

Também não adianta declarar n1 e n2 como estáticas dentro da função lambda. As expressões lambda de C++ não são nada além de uma facilidade da linguagem, um syntatic sugar. O que o compilador fará, na realidade, será criar uma função comum com o código de definição da expressão lambda em uma outra região de memória, com um nome que só ele conhece. Então ele passará um ponteiro para essa função dentro de uma variável do tipo function<int()>. Não importa quantos objetos você crie com fib, todos chamarão uma mesma função estática, com as mesmas variáveis estáticas.

Solução Final


A solução para o nosso problema passa por uma técnica conhecida de C++, objetos-função, ou functors.

Estes objetos nada mais são do que classes que sobrecarregam operator () para que suas instâncias possam ser "invocadas" com a mesma sintaxe com que se invoca funções. Como classes podem ter variáveis membro, com contextos e espaço de memória separados, uma instancia do functor não tem qualquer influência sobre outras instancias.

Assim, temos o nosso gerador de sequência Fibonacci usando um functor.

#include <cstdio>

class Fib {
    int n1, n2;

public:
    Fib(): n1(0), n2(1) { }

    int operator()() {
        int temp = n1 + n2;
        n1 = n2; n2 = temp;
        return temp;
    }
};

int main() {
    Fib f, g;

    for(int i=0; i < 10; i++) {
        std::printf("%d\t%d\n", f(), g());
    }

    return 0;
}
Resultado:
1    1
2    2
3    3
5    5
8    8
13    13
21    21
34    34
55    55
89    89

Conclusão


Ao final deste experimento, você deve estar se perguntando qual a vantagem que funções lambda trouxeram a C++, já que elas não proporcionam nada que não fosse possível anteriormente sem elas. A resposta é "contexto".

Como as funções lambda são declaradas apenas no momento em que são utilizadas, elas tem o benefício implícito de dar maior legibilidade ao código. Eu não preciso procurar nos arquivos de código-fonte do meu projeto o que a função ou functor xyz faz quando invocada com std::for_each, por exemplo. O código está no mesmo local em que é chamado, dando uma visão melhor de como ele se relaciona e modifica o contexto no qual está inserido. Elas nos ajudam a programar melhor e cometer menos erros, ou ao menos identificá-los mais facilmente.

Portanto, lambdas tem sua utilidade em C++. Elas, com certeza, dão novo fôlego à linguagem, e ao nosso código. Mas ainda é C++.

8 comentários:

  1. Homero, texto muito bem explicado, abordagem mto interessante! Gostaria de sugerir que marcasse no texto links para coisas como por exemplo os lambdas, pois como você mesmo disse, são novidades e eu não conhecia!

    ResponderExcluir
    Respostas
    1. Obrigado!

      Sugestão perfeitamente pertinente, vou tomar cuidado com isso da próxima vez. :)

      Excluir
  2. Bem, no fim das contas você acabou usando um functor, que não é novidade no C++.

    Nunca li muito sobre Go mas, a julgar pelo trecho de código que você examinou e mostrou aqui, parece que funções são objetos de primeira classe, com closures que realmente funcionam.

    Bem, na verdade eu tinha esse negócio de "first-class object" na cabeça, mas não sabia muito bem o que era (peço um desconto porque eu não sou cientista da computação nem desenvolvedor, mas um engenheiro que trabalhar com infraestrutura de TI). Tive que ler um bocado na Wikipedia sobre o conceito, e, a partir dele, sobre first-class functions, higher-order functions e closures. E uma informação que eu vi lá é que é essencial para se poder ter funções de primeira classe o uso de garbage collection.

    No fim das contas, o que faz diferença é o fato de que Go, assim como outras linguagens que possibilitam tal construção, aparentemente trabalha somente com alocação dinâmica na memória livre (heap) e garbage collector, em lugar de usar a pilha para variáveis automáticas, como fazem C++ e C. No exemplo do programa em Go com duas instâncias de fib(), duas closures foram geradas para dois pares de variáveis n1 e n2 que foram alocados na memória livre em posições distintas.

    C++, bem como C, não tem GC e suas variáveis locais são colocadas na pilha. Isso, de cara, já impede a tradução quase literal que você mencionou no princípio do artigo.

    Para conseguir um efeito mais ou menos parecido, o mais correto teria sido trocar trocar as declarações de n1 e n2 não por alocação estática, mas provavelmente por ponteiros que logo em seguida recebem endereços alocados da memória livre (com new, por exemplo). Além disso, a passagem para a função lambda deve ser feita por valor ([=]), em lugar de por referência, pois o que interessa é acessar os valores colocados na memória alocada, cujos endereços serão copiados para a função lambda, não as variáveis locais da função mais externa, que, mesmo tendo sido feitas ponteiros, continuarão sendo perdidas assim que fib() terminar.

    Mas ainda assim não se vai obter o efeito pleno de closures, uma vez que mesmo depois que f e g (em main()) saírem de escopo, não vai haver um destrutor que libere a memória que foi alocada, e não se dispõe do GC.

    Eu só não analisei se o efeito do GC poderia ser conseguido com alguma esperteza usando shared_ptr e weak_ptr. No entanto, eu tenho o sentimento de que ainda não daria.

    Não tenho dúvidas de que functors são uma solução melhor em C++, não apenas em termos de desempenho, mas também porque permitem fazer RAII plenamente, adicionando construtores e destrutores apropriados quando necessário.

    ResponderExcluir
    Respostas
    1. Caramba...

      Fiquei com a pulga atrás da orelha e fui pesquisar (lá se vai outra noite de sono -- para quem começou as férias com uma disciplina de sono exemplar, estes últimos dias de férias foram totalmente pé na jaca).

      O que me deixou encucado foi a "cópia" dos valores, que eu mencionei acima, junto com a lembrança de ter lido (agora não lembro onde, mas certamente num dos documentos que eu li enquanto estava fazendo testes e escrevendo a resposta acima) que uma função lambda em C++ é apenas syntatic sugar para um functor.

      "Se é syntatic sugar, então o que o Homero queria tem de funcionar sem tanta complicação!"

      A primeira coisa que eu tentei fazer foi o seguinte:

      function func2() {
        int n1=1, n2=1;
        return [=]() {
          int temp;
          temp=n1;
          n1=n2;
          n2+=temp;
          return temp;
        };
      }


      Só que essa tentativa ocasionou o seguinte:

      lixo.cc:74:6: error: assignment of member ‘func2()::::n1’ in read-only object
      lixo.cc:75:7: error: assignment of member ‘func2()::::n2’ in read-only object


      Que raio de read-only object é esse?

      E lá fui eu fazer mais pesquisas. E li o seguinte documento (um dos trechos de drafts do que veio a se tornar o C++11): (http://www.open-std.org/JTC1/SC22/WG21/docs/papers/2009/n2927.pdf). Desse documento, que é basicamente a definição de lambdas na linguagem -- RTFM! --, são de se destacar, na seção 5.5.1, os parágrafos 14, onde se lê "An entity is captured by copy if it is implicitly captured and the capture-default is =, or if it is explicitly captured with a capture that does not include a &. For each entity captured by copy, an unnamed non-static data member is declared in the closure type.", e 5, que diz "The closure type for a lambda-expression has a public inline function call operator (_over.call_ 13.5.4) whose parameters and return type are described by the lambda-expression's parameter-declaration-clause and trailing-return-type respectively. This function call operator is declared const (_class.mfct.non-static_ 9.3.1) if and only if the lambda-expression's parameter-declaration-clause is not followed by mutable."

      EUREKA!

      O código equivalente ao exemplo em Go ficou, de novo, trivial, mas agora correto.


      function fib() mutable {
        int n1=1, n2=1;
        return [=]() {
          int temp;
          temp=n1;
          n1=n2;
          n2+=temp;
          return temp;
        };
      }


      Caramba... Pelo visto a curva de aprendizagem do C++11 tem chances de ser bem inclinada.

      Homero, desculpe pelo espaço que eu tomei no seu blog, e ainda por cima falando as besteiras pseudo-doutas do post anterior, da qual talvez apenas a passagem de objetos por cópia se aproveita. "RTFM" para mim mesmo!

      Excluir
    2. Olá Paulo,

      Cara, fiquei muito impressionado com sua resposta e gostaria de utilizá-la para atualizar o post, se não se importar. Não precisa se desculpar por tomar espaço, a idéia é ter a mesma interação que outros blogs de programação tem la fora.

      Eu tenho uma versão mais recente do paper, de 2010 (WG21 N3092). Tem algumas coisas diferentes, mas encontrei os parágrafos referidos. Prometo que da próxima vez pesquisarei melhor para que não perca horas de sono para me corrigir. ;)

      Gostaria de saber qual versão do GCC está utilizando. Aqui estou utilizando o MingW com GCC 4.6.2 e seu código não compilou exatamente da mesma forma, mas com algumas modificações.

      Primeiramente, fib() não pode ser uma função comum, tem de ser um lambda também. Se isso não for feito, o código compila, mas falha em linkar, pois uma função comum não possui os destrutores necessários.

      Outra coisa que teve de ser feita foi mover o qualificador mutable de fib para a lambda interna.

      Talvez seja melhor fazer um outro post mostrando que lambdas, afinal, fazem mágica. :)

      Excluir
  3. Oops! Foi mal!

    Na hora de colar as duas versões, da função, eu acabei editando manualmente e coloquei o mutable no lugar errado: deveria ter sido na definição do lambda, não da função externa. Ou seja, o que compilou aqui (Ubuntu amd64 com GCC 4.6.1) foi o seguinte:

    function func2() {
        int n1=1, n2=1;
        return [=]() mutable {
            int temp;
            temp=n1;
            n1=n2;
            n2+=temp;
            return temp;
        };
    }


    Eu coloquei no codepad o código do programa que eu usei para fazer testes, no qual eu inclusive criei uma classe que envelopava o int nativo, a fim de acompanhar todas as construções e descontruções, para saber se haveria algum memory leak. Não vi nada parecido com o que você falou agora sobre a função externa também ter que ser um lambda, nem sobre faltarem destrutores. Tem como você ver se o meu código compila aí, enquanto eu vejo se já tem algum GCC mais novo no repositório do Ubuntu?

    Nada do que eu escrevi tem restrição alguma -- até porque já está publicado. Se for útil, pode usar.

    ResponderExcluir
    Respostas
    1. Compilou sem problemas aqui, funciona perfeitamente.

      Estranho que anteriormente ele não tenha conseguido linkar, até porque dava um erro com relação a ausência de destrutores. Depois de compilar seu programa, recompilei a minha versão que não usa dois lambdas, e linkou sem problemas. Creio que seja algum bug do MingW, já que não usei nenhuma opção estranha ao compilador:

      g++ -o arquivo.exe -std=c++0x arquivo.cpp

      Obrigado por dar permissão, mais tarde atualizarei o blog com suas descobertas.

      Abraços

      Excluir
  4. Fiz mais alguns testes com uma versão modificada daquele meu programa, usando std::shared_ptr no lambda e aquele meu tipo int envelopado, para mostrar construções e desconstruções.

    Usando smart pointers houve menos construções e desconstruções, e não houve vazamento de memória.

    ResponderExcluir