Quarta-feira, Março 31, 2010

O estilo funcional em C++

Estava de mau humor e resolvi ironizar a vida estudando numa aula de Java o artigo "Why Functional Programming Matters", que eu copiei do site do Haskell.

Acabei finalmente entendendo (eu acho!) as primeiras implicações importantes de lazy evaluation. O exemplo mais simples dessas implicações no artigo, na minha opinião, está no programa que computa aproximações da raiz quadrada pelo método de Newton-Raphson.

Esse método é basicamente uma expressão matemática que começa com um resultado anterior e usa esse resultado para computar um próximo, garantindo que o próximo é melhor que o anterior.

Seja N o número cuja raiz quadrada você deseja aproximar, e uma aproximação anterior x. O procedimento começa chutando x, por exemplo 1. O método afirma que o próximo x deve ser ( x + ( N / x ) / 2 .

O artigo descreve um programa para resolver o método. O programa segue abaixo; para entendê-lo completamente, estude o artigo.


repeat f a = cons a ( repeat f ( f a ) )

within e ( cons a ( cons b rest ) ) =
  = b , if abs(a - b) <= eps
  = within e ( cons b rest), otherwise

next N x = ( x + N / x ) / 2

sqrt a0 e N = within e ( repeat ( next N ) a0 )

O espírito geral do programa é assim:

repeat é uma função que gera uma lista contendo um elemento inicial seguido do resultado da função f com argumento elemento-anterior. Observe que repeat gera uma lista infinita, assumindo que f está definida para todo a.

within é uma função que percorre uma lista procurando um par de elementos cuja diferença seja menor que um epsilon.

A notação de parâmetros em within faz o que eu chamo de "desempacotar a lista". O segundo argumento é uma lista; a definição de within desempacota os dois primeiros, chamando-os de a e b.

next computa a próxima aproximação da raiz quadrada de N com aproximação anterior x segundo o método de Newton-Raphson.

sqrt computa a raiz quadrada de um número conforme uma tolerância e e um chute inicial a0; sua definição aplica within a repeat, e repeat a next.

sqrt basicamente avalia within à lista gerada por repeat , e repeat gera a lista de resultados de next.

Este programa, segundo a intuição de um programador acostumado a linguagens imperativas, nunca termina, porque repeat nunca termina de gerar a lista infinita.

Porém, lazy evaluation garante que esse programa termina -- garante que repeat será avaliado apenas o número de vezes necessária para que within se satisfaça. Eu entendo que o segredo disso está no momento em que o interpretador avalia o "desempacotamento" de uma lista, porque este é o momento em que o processamento exige a geração de mais um elemento.

Isso é mesmo sensacional. Como seriam esses pequenos componentes em C++0x?

Vamos começar brincando com uma versão muito eficiente de abs, o que não tem nada a ver com o problema em questão.

template
auto abs (Number x) -> typename std::enable_if< std::is_signed::value, Number>::type
{
  return x < 0 ? -x : x ;
}

template
auto abs (Number x) -> typename std::enable_if< ! std::is_signed::value, Number>::type
{
  return x;
}

A notação acima é a nova notação para funções em C++0x, onde a especificação do retorno fica depois da lista de argumentos.

A definição de abs faz uso de SFINAE para sobrecarregar dois templates, de outro modo indistinguíveis, usando std::enable_if.

Assim, abs pode se reduzir a nada quando nem mesmo faz sentido que o número possua um sinal.

Em seguida, vamos chamar as listas de Range e declarar a forma geral de uma função unpack.

template
auto unpack (Range& r) -> Value;

template
auto unpack (Sequence& s) -> decltype(s.front())
{
  typename Sequence::value_type v = s.front();
  s.pop_front();
  return v;
}

Definimos o significado de unpack providenciando uma sobrecarga, exclusiva para Containers da STL, que faz o que deve fazer. Em português, unpack resulta o elemento à esquerda e modifica a lista para conter o resto.

Assim, definimos within:

namespace detail
{

  template
  auto within (Number eps, Number a, Number b, Range rest) -> Number
  {
    if (abs(a - b) <= eps) return b;
    else {
      const Number c = unpack(rest);
      return within(eps, b, c, rest);
    }
  }

}

template
// requires(ValueType(Range) == Number)
auto within (Number eps, Range list) -> Number
{
  const Number a = unpack(list);
  const Number b = unpack(list);
  return detail::within(eps, a, b, list);
}

within desempacota a e b e delega para uma subrotina; a subrotina faz o que se espera. Observe que a lista a processar é um desses tais Ranges.

O componente equivalente a repeat deve gerar esse Range.

namespace detail
{
  template
  struct repeat_range
  {
    Function f;
    Value x;

    auto operator() () -> decltype(f(x))
    {
      return x = f(x);
    }
  };

  template
  auto unpack (repeat_range& r) -> decltype(r())
  {
    return r();
  }
}

template
// requires(UnaryFunction(Function) && Domain(Function) == Value)
auto repeat (Function f, Value a) -> detail::repeat_range
{
  return detail::repeat_range { f, a };
}

Um Range é um objeto especial, que representa a lista; definimos um repeat_range que continuamente aplica uma função, e definimos unpack para este Range. (Observe como repeat_range usa o último valor gerado na geração do próximo.)
A função repeat é utilitária e constrói o Range com os argumentos certos.

A notação de construção usada na definição de repeat também é nova, e generaliza a inicialização entre structs C e objetos C++.

Por último, implementamos a função importante para a aplicação, sqrt. Escolhi ocultar o equivalente a next porque a mim não interessa muito.

namespace detail
{
  template
  auto sqrt (Number N, Number x) -> Number
  {
    return (x + (N / x)) / 2;
  }
}

template
auto sqrt (Number N, Number x, Number eps) -> Number
{
  return
    within(eps,
      repeat(
        [=] (Number x) { return detail::sqrt(N, x); },
        x
      )
    );
}

Agora, isso é punk. A função no detail computa a próxima aproximação é trivial. O driver principal, por outro lado, é complexo.

Em primeiro lugar, aquela notação esquisita ali é uma expressão lambda. Para todos os efeitos, a avaliação desta expressão produz uma função, que vira argumento para repeat.

repeat, portanto, aplicará uma função lambda -- que por sua vez computa a próxima aproximação da raiz de N com chute x -- ao valor inicial x. O símbolo [=] significa que os nomes dentro do lambda se tornam cópias por valor dos objetos de mesmo nome no escopo maior.

repeat, como vimos antes, produz um Range, sobre o qual within opera. E within procura um par de valores neste Range -- aplicando unpack quando necessário -- em busca de um par cuja diferença seja menor que eps.

Assim, nesmo programa C++, simulamos com significativo esforço a lazy evaluation funcional, para computar a raiz quadrada. Usando __attribute__((const)) esse programa deve otimizar até o Nirvana, mas eu não verifiquei.

O programa esparramado no texto acima funciona de verdade com o GCC 4.5 e provavelmente funciona também com o ICC 11.1.

0 comentários: