Introdução à programação funcional em F#

A programação funcional é um estilo de programação que enfatiza o uso de funções e dados imutáveis. A programação funcional tipada é quando a programação funcional é combinada com tipos estáticos, como com F#. Em geral, os seguintes conceitos são enfatizados na programação funcional:

  • Funções como os constructos primários que você usa
  • Expressões ao invés de instruções
  • Valores imutáveis ao invés de variáveis
  • Programação declarativa ao invés de programação imperativa

Ao longo desta série, você explorará conceitos e padrões na programação funcional usando F#. No processo, você também aprenderá um pouco sobre F#.

Terminologia

A programação funcional, como outros paradigmas de programação, vem com um vocabulário que você eventualmente precisará aprender. Aqui estão alguns termos comuns que você verá o tempo todo:

  • Função – Uma função é um constructo que produzirá uma saída quando receber uma entrada. Mais formalmente, ela mapeia um item de um conjunto para outro. Esse formalismo é concretamente levantado de muitas maneiras, especialmente ao usar funções que operam em coleções de dados. É o conceito mais básico (e importante) na programação funcional.
  • Expressão – Uma expressão é um constructo no código que produz um valor. Em F#, esse valor deve ser associado ou explicitamente ignorado. Uma expressão pode ser substituída trivialmente por uma chamada de função.
  • Pureza – A pureza é uma propriedade de uma função de modo que seu valor retornado é sempre o mesmo para os mesmos argumentos, e que sua avaliação não tem efeitos colaterais. Uma função pura depende inteiramente de seus argumentos.
  • Transparência referencial – Transparência referencial é uma propriedade de expressões de modo que elas possam ser substituídas por sua saída sem afetar o comportamento de um programa.
  • Imutabilidade – Imutabilidade significa que um valor não pode ser alterado no local. Isso contrasta com variáveis que podem ser alteradas no local.

Exemplos

Os exemplos a seguir demonstram esses conceitos básicos.

Funções

O constructo mais comum e fundamental na programação funcional é a função. Aqui está uma função simples que adiciona 1 a um inteiro:

let addOne x = x + 1

Sua assinatura de tipo é a seguinte:

val addOne: x:int -> int

A assinatura pode ser lida como "addOne aceita um int chamado x e produzirá um int". Mais formalmente, addOne é mapear um valor do conjunto de inteiros para o conjunto de inteiros. O token -> significa esse mapeamento. Em F#, você geralmente pode examinar a assinatura da função para ter uma noção do que ela faz.

Então, por que a assinatura é importante? Na programação funcional tipada, a implementação de uma função geralmente é menos importante do que a assinatura de tipo real! O fato de que addOne adiciona o valor 1 a um inteiro é interessante no tempo de execução, mas, quando você está construindo um programa, o fato de ele aceitar e retornar um int é o que informa como você realmente usará essa função. Além disso, depois de usar essa função corretamente (com relação à assinatura de tipo), diagnosticar quaisquer problemas só poderá ser feito dentro do corpo da função addOne. Esse é o ímpeto por trás da programação funcional tipada.

Expressões

Expressões são construções que são avaliadas como um valor. Ao contrário das instruções, que executam uma ação, as expressões podem ser pensadas para executar uma ação que devolve um valor. As expressões são quase sempre usadas em programação funcional em vez de instruções.

Considere a função anterior, addOne. O corpo de addOne é uma expressão:

// 'x + 1' is an expression!
let addOne x = x + 1

É o resultado dessa expressão que define o tipo de resultado da função addOne. Por exemplo, a expressão que compõe essa função pode ser alterada para ser um tipo diferente, como um string:

let addOne x = x.ToString() + "1"

A assinatura da função agora é:

val addOne: x:'a -> string

Como qualquer tipo em F# pode ter ToString() chamado nele, o tipo de x foi tornado genérico (chamado Generalização automática) e o tipo resultante é um string.

Expressões não são apenas os corpos das funções. Você pode ter expressões que produzem um valor usado em outro lugar. Um comum é if:

// Checks if 'x' is odd by using the mod operator
let isOdd x = x % 2 <> 0

let addOneIfOdd input =
    let result =
        if isOdd input then
            input + 1
        else
            input

    result

A expressão if produz um valor chamado result. Observe que você pode omitir result totalmente, tornando a expressão if o corpo da função addOneIfOdd. O principal a ser lembrado sobre expressões é que elas produzem um valor.

Há um tipo especial, unit, que é usado quando não há nada a retornar. Por exemplo, considere esta função simples:

let printString (str: string) =
    printfn $"String is: {str}"

A assinatura é semelhante ao seguinte:

val printString: str:string -> unit

O tipo unit indica que não há nenhum valor real sendo retornado. Isso é útil quando você tem uma rotina que deve "trabalhar" apesar de não ter nenhum valor para retornar como resultado desse trabalho.

Isso contrasta fortemente com a programação imperativa, em que o constructo equivalente if é uma instrução e a produção de valores geralmente é feita com variáveis de mutação. Por exemplo, em C#, o código pode ser escrito da seguinte maneira:

bool IsOdd(int x) => x % 2 != 0;

int AddOneIfOdd(int input)
{
    var result = input;

    if (IsOdd(input))
    {
        result = input + 1;
    }

    return result;
}

Vale a pena observar que C# e outras linguagens de estilo C dão suporte à expressão ternária, que permite programação condicional baseada em expressão.

Na programação funcional, é raro alterar valores com instruções. Embora algumas linguagens funcionais ofereçam suporte a instruções e mutações, não é comum usar esses conceitos na programação funcional.

Funções puras

Como mencionado anteriormente, funções puras são funções que:

  • Sempre avalie para o mesmo valor para a mesma entrada.
  • Não tem efeito colateral.

É útil pensar em funções matemáticas nesse contexto. Em matemática, as funções dependem apenas de seus argumentos e não têm efeito colateral. Na função matemática f(x) = x + 1, o valor de f(x) depende apenas do valor de x. Funções puras na programação funcional são da mesma forma.

Ao escrever uma função pura, a função deve depender apenas de seus argumentos e não executar nenhuma ação que resulte em um efeito colateral.

Aqui está um exemplo de uma função não pura porque depende do estado global mutável:

let mutable value = 1

let addOneToValue x = x + value

A função addOneToValue é claramente impura, pois value pode ser alterado a qualquer momento para ter um valor diferente de 1. Esse padrão de depender de um valor global deve ser evitado na programação funcional.

Aqui está outro exemplo de uma função não pura, pois executa um efeito colateral:

let addOneToValue x =
    printfn $"x is %d{x}"
    x + 1

Embora essa função não dependa de um valor global, ela grava o valor de x para a saída do programa. Embora não haja nada inerentemente errado em fazer isso, isso significa que a função não é pura. Se outra parte do programa depender de algo externo ao programa, como o buffer de saída, chamar essa função poderá afetar essa outra parte do programa.

Remover a instrução printfn torna a função pura:

let addOneToValue x = x + 1

Embora essa função não seja inerentemente melhor do que a versão anterior com a instrução printfn, ela garante que tudo o que essa função faça seja retornar um valor. Chamar essa função várias vezes produz o mesmo resultado: ela apenas produz um valor. A previsibilidade dada pela pureza é algo pelo qual muitos programadores funcionais se esforçam.

Imutabilidade

Por fim, um dos conceitos mais fundamentais da programação funcional tipada é a imutabilidade. Em F#, todos os valores são imutáveis por padrão. Isso significa que eles não podem ser modificados no local, a menos que você os marque explicitamente como mutáveis.

Na prática, trabalhar com valores imutáveis significa que você altera sua abordagem para a programação de, "Preciso mudar algo", para "Preciso produzir um novo valor".

Por exemplo, adicionar 1 a um valor significa produzir um novo valor, sem alterar o existente:

let value = 1
let secondValue = value + 1

Em F#, o código a seguir não altera a função value; em vez disso, ele executa uma verificação de igualdade:

let value = 1
value = value + 1 // Produces a 'bool' value!

Algumas linguagens de programação funcionais não dão suporte a mutação. Em F#, há suporte, mas não é o comportamento padrão para valores.

Esse conceito se estende ainda mais às estruturas de dados. Na programação funcional, estruturas de dados imutáveis, como conjuntos (e muitos outros), têm uma implementação diferente do esperado inicialmente. Conceitualmente, algo como adicionar um item a um conjunto não altera o conjunto, mas produz um novo conjunto com o valor adicionado. Nos bastidores, isso geralmente é feito por uma estrutura de dados diferente que permite o acompanhamento eficiente de um valor para que a representação apropriada dos dados possa ser fornecida como resultado.

Esse estilo de trabalho com valores e estruturas de dados é fundamental, pois força você a tratar qualquer operação que modifique algo como se criasse uma nova versão dessa coisa. Isso permite que coisas como igualdade e comparação sejam consistentes em seus programas.

Próximas etapas

A próxima seção abordará completamente as funções, explorando diferentes maneiras de usá-las na programação funcional.

O uso de funções em F# explora profundamente as funções, mostrando como você pode usá-las em vários contextos.

Leitura adicional

A série Pensar funcionalmente é outro ótimo recurso para aprender sobre programação funcional com F#. Ela aborda os conceitos básicos da programação funcional de forma pragmática e fácil de ler, usando recursos F# para ilustrar os conceitos.