Convenções de codificação do F#

As convenções a seguir foram formuladas com base na experiência de trabalho com grandes bases de código F#. Os Cinco princípios de um bom código F# são a base de cada recomendação. Eles estão relacionados às diretrizes de design de componentes F#, mas são aplicáveis a qualquer código F#, não apenas a componentes como bibliotecas.

Organização do código

O F# traz duas maneiras principais de organizar o código: módulos e namespaces. Eles são semelhantes, mas têm as seguintes diferenças:

  • Os namespaces são compilados como namespaces .NET. Os módulos são compilados como classes estáticas.
  • Os namespaces são sempre de nível superior. Os módulos podem ser de nível superior e aninhados em outros módulos.
  • Os namespaces podem abranger vários arquivos. Isso já não ocorre com os módulos.
  • Os módulos podem ser decorados com [<RequireQualifiedAccess>] e [<AutoOpen>].

As diretrizes a seguir ajudarão você a usá-los para organizar seu código.

Preferência de namespaces no nível superior

Para qualquer código publicamente consumível, os namespaces são preferenciais aos módulos no nível superior. Como eles são compilados como namespaces .NET, são consumíveis em C# sem recorrer a using static.

// Recommended.
namespace MyCode

type MyClass() =
    ...

O uso de um módulo de nível superior pode não parecer diferente quando chamado somente em F#, mas para os consumidores de C#, os chamadores poderão se surpreender por precisarem qualificar MyClass com o módulo MyCode quando não estiverem cientes do constructo C# using static específico.

// Will be seen as a static class outside F#
module MyCode

type MyClass() =
    ...

Aplicação cuidadosa de [<AutoOpen>]

O constructo [<AutoOpen>] pode poluir o escopo do que está disponível para os chamadores, e a resposta para a origem de algo é "magic". Isso não é algo bom. Uma exceção a essa regra é a própria Biblioteca Principal F# (embora esse fato também seja um pouco controverso).

No entanto, é uma conveniência se você tem uma funcionalidade auxiliar para uma API pública que deseja organizar separadamente dessa API pública.

module MyAPI =
    [<AutoOpen>]
    module private Helpers =
        let helper1 x y z =
            ...

    let myFunction1 x =
        let y = ...
        let z = ...

        helper1 x y z

Isso permite separar de maneira limpa os detalhes da implementação da API pública de uma função sem precisar qualificar totalmente um auxiliar sempre que você chamá-lo.

Além disso, a exposição de métodos de extensão e construtores de expressões no nível do namespace pode ser claramente expressa com [<AutoOpen>].

Use [<RequireQualifiedAccess>] sempre que os nomes puderem entrar em conflito ou você achar que isso ajuda na leitura

A adição do atributo [<RequireQualifiedAccess>] a um módulo indica que o módulo pode não ser aberto e que as referências aos elementos do módulo exigem o acesso qualificado explícito. Por exemplo, o módulo Microsoft.FSharp.Collections.List tem esse atributo.

Isso é útil quando as funções e os valores no módulo têm nomes que provavelmente entrarão em conflito com os nomes em outros módulos. Exigir o acesso qualificado pode aumentar consideravelmente a capacidade de manutenção de longo prazo e de evolução de uma biblioteca.

[<RequireQualifiedAccess>]
module StringTokenization =
    let parse s = ...

...

let s = getAString()
let parsed = StringTokenization.parse s // Must qualify to use 'parse'

Classificação topológica de instruções open

Em F#, a ordem das declarações é importante, inclusive nas instruções open (e open type, apenas referenciadas como open mais abaixo). Isso é diferente de C#, em que o efeito de using e using static é independente da ordenação dessas instruções em um arquivo.

Em F#, os elementos abertos em um escopo podem sombrear outros já presentes. Isso significa que a reordenação de instruções open pode alterar o significado do código. Como resultado, qualquer classificação arbitrária de todas as instruções open (por exemplo, de maneira alfanumérica) não é recomendada, para que você não gere um comportamento diferente do esperado.

Em vez disso, recomendamos que você as classifique topologicamente, ou seja, coloque as instruções open na ordem em que as camadas do sistema são definidas. A classificação alfanumérica em diferentes camadas topológicas também pode ser considerada.

Como exemplo, esta é a classificação topológica para o arquivo de API pública do serviço de compilador F#:

namespace Microsoft.FSharp.Compiler.SourceCodeServices

open System
open System.Collections.Generic
open System.Collections.Concurrent
open System.Diagnostics
open System.IO
open System.Reflection
open System.Text

open FSharp.Compiler
open FSharp.Compiler.AbstractIL
open FSharp.Compiler.AbstractIL.Diagnostics
open FSharp.Compiler.AbstractIL.IL
open FSharp.Compiler.AbstractIL.ILBinaryReader
open FSharp.Compiler.AbstractIL.Internal
open FSharp.Compiler.AbstractIL.Internal.Library

open FSharp.Compiler.AccessibilityLogic
open FSharp.Compiler.Ast
open FSharp.Compiler.CompileOps
open FSharp.Compiler.CompileOptions
open FSharp.Compiler.Driver

open Internal.Utilities
open Internal.Utilities.Collections

Uma quebra de linha separa as camadas topológicas, com cada camada sendo classificada de maneira alfanumérica depois. Isso organiza o código de maneira limpa sem sombrear os valores acidentalmente.

Uso de classes para conter valores que têm efeitos colaterais

Há muitas vezes em que a inicialização de um valor pode ter efeitos colaterais, como a criação de uma instância de um contexto para um banco de dados ou outro recurso remoto. É tentador inicializar essas coisas em um módulo e usá-lo em funções posteriores:

// Not recommended, side-effect at static initialization
module MyApi =
    let dep1 = File.ReadAllText "/Users/<name>/connectionstring.txt"
    let dep2 = Environment.GetEnvironmentVariable "DEP_2"

    let private r = Random()
    let dep3() = r.Next() // Problematic if multiple threads use this

    let function1 arg = doStuffWith dep1 dep2 dep3 arg
    let function2 arg = doStuffWith dep1 dep2 dep3 arg

Isso costuma ser um problema por alguns motivos:

Primeiro, a configuração do aplicativo é enviada por push para a base de código com dep1 e dep2. Isso é difícil de ser mantido em bases de código maiores.

Em segundo lugar, os dados inicializados estaticamente não devem incluir valores que não são thread-safe se o próprio componente usa vários threads. Isso é claramente violado por dep3.

Por fim, a inicialização do módulo é compilada em um construtor estático para toda a unidade de compilação. Se ocorrer algum erro na inicialização de valor com limite de permissão nesse módulo, ele se manifestará como uma TypeInitializationException que será armazenada em cache durante todo o tempo de vida do aplicativo. Isso pode ser difícil de ser diagnosticado. Geralmente, há uma exceção interna que você pode tentar entender, mas se não houver, não haverá como saber qual é a causa raiz.

Em vez disso, basta usar uma classe simples para manter as dependências:

type MyParametricApi(dep1, dep2, dep3) =
    member _.Function1 arg1 = doStuffWith dep1 dep2 dep3 arg1
    member _.Function2 arg2 = doStuffWith dep1 dep2 dep3 arg2

Isso possibilita o seguinte:

  1. Push de qualquer estado dependente para fora da própria API.
  2. A configuração agora pode ser feita fora da API.
  3. Os erros na inicialização para valores dependentes provavelmente não se manifestarão como uma TypeInitializationException.
  4. A API agora é mais fácil de ser testada.

Gerenciamento de erros

O gerenciamento de erros em sistemas grandes é um esforço complexo cheio de nuances, e não há soluções mágicas que garantam que os seus sistemas sejam tolerantes a falhas e se comportem bem. As diretrizes a seguir devem oferecer orientações para superar esse espaço difícil.

Representação de casos de erro e estado ilegal em tipos intrínsecos ao domínio

Com as uniões discriminadas, o F# oferece a capacidade de representar o estado do programa com falha no sistema de tipos. Por exemplo:

type MoneyWithdrawalResult =
    | Success of amount:decimal
    | InsufficientFunds of balance:decimal
    | CardExpired of DateTime
    | UndisclosedFailure

Nesse caso, há três maneiras conhecidas pelas quais a retirada de dinheiro de uma conta bancária pode falhar. Cada caso de erro é representado no tipo e, portanto, pode ser tratado com segurança em todo o programa.

let handleWithdrawal amount =
    let w = withdrawMoney amount
    match w with
    | Success am -> printfn $"Successfully withdrew %f{am}"
    | InsufficientFunds balance -> printfn $"Failed: balance is %f{balance}"
    | CardExpired expiredDate -> printfn $"Failed: card expired on {expiredDate}"
    | UndisclosedFailure -> printfn "Failed: unknown"

Em geral, se você puder modelar as diferentes maneiras pelas quais algo pode falhar no seu domínio, o código de tratamento de erro não será mais tratado como algo com o qual você precisa lidar além do fluxo normal do programa. É simplesmente uma parte do fluxo normal do programa e não é considerado excepcional. Existem dois benefícios principais nisso:

  1. É mais fácil de ser mantido conforme o domínio muda ao longo do tempo.
  2. O teste de unidade dos casos de erro é mais fácil de ser realizado.

Uso de exceções quando os erros não puderem ser representados com tipos

Nem todos os erros podem ser representados em um domínio de problema. Esses tipos de falhas são excepcionais por natureza, daí a capacidade de gerar e capturar exceções em F#.

Primeiro, é recomendável que você leia as Diretrizes de design de exceções. Elas também são aplicáveis a F#.

Os constructos principais disponíveis em F# para fins de geração de exceções devem ser considerados na seguinte ordem de preferência:

Função Sintaxe Finalidade
nullArg nullArg "argumentName" Gera uma System.ArgumentNullException com o nome do argumento especificado.
invalidArg invalidArg "argumentName" "message" Gera uma System.ArgumentException com um nome de argumento e uma mensagem especificados.
invalidOp invalidOp "message" Gera uma System.InvalidOperationException com a mensagem especificada.
raise raise (ExceptionType("message")) Mecanismo de uso geral para gerar exceções.
failwith failwith "message" Gera uma System.Exception com a mensagem especificada.
failwithf failwithf "format string" argForFormatString Gera uma System.Exception com uma mensagem determinada pela cadeia de caracteres de formato e as respectivas entradas.

Use nullArg, invalidArg e invalidOp como o mecanismo para gerar ArgumentNullException, ArgumentException e InvalidOperationException, quando apropriado.

Geralmente, as funções failwith e failwithf devem ser evitadas porque geram o tipo Exception base, não uma exceção específica. De acordo com as Diretrizes de design de exceções, o ideal é gerar exceções mais específicas quando possível.

Uso da sintaxe de tratamento de exceções

O F# dá suporte a padrões de exceção por meio da sintaxe try...with:

try
    tryGetFileContents()
with
| :? System.IO.FileNotFoundException as e -> // Do something with it here
| :? System.Security.SecurityException as e -> // Do something with it here

Reconciliar a funcionalidade a ser executada diante de uma exceção com padrões correspondentes pode ser um pouco complicado se você quer manter o código limpo. Uma dessas formas de lidar com isso é usar padrões ativos como um meio de agrupar a funcionalidade em torno de um caso de erro com uma exceção em si. Por exemplo, você pode consumir uma API que, quando gera uma exceção, inclui informações importantes nos metadados de exceção. Cancelar a quebra de linha de um valor útil no corpo da exceção capturada no padrão ativo e retornar esse valor pode ser útil em algumas situações.

Não uso do tratamento de erro monádico para substituir exceções

As exceções são frequentemente vistas como tabu no paradigma funcional puro. De fato, as exceções violam a pureza e, portanto, é seguro considerá-las não muito funcionalmente puras. No entanto, isso ignora a realidade do local em que o código precisa ser executado e que os erros de runtime podem ocorrer. Em geral, escreva o código supondo que a maioria dos itens não é puro ou total, a fim de minimizar surpresas desagradáveis (semelhante a um catch vazio em C# ou ao gerenciamento inadequado do rastreamento de pilha, com o descarte de informações).

É importante considerar os seguintes pontos fortes/aspectos principais das exceções com relação à relevância e à adequação no runtime do .NET e no ecossistema de várias linguagens como um todo:

  • Elas contêm informações detalhadas de diagnóstico, o que é útil na depuração de um problema.
  • Elas são bem compreendidas pelo runtime e por outras linguagens do .NET.
  • Elas podem reduzir textos clichês significativos quando comparado com o código que faz o máximo possível para evitar exceções implementando um subconjunto de semântica ad hoc.

Este terceiro ponto é crítico. Para operações complexas não triviais, o não uso de exceções pode fazer com que você precise lidar com estruturas como esta:

Result<Result<MyType, string>, string list>

O que pode facilmente resultar em um código frágil, como padrões correspondentes em erros "tipados com cadeia de caracteres":

let result = doStuff()
match result with
| Ok r -> ...
| Error e ->
    if e.Contains "Error string 1" then ...
    elif e.Contains "Error string 2" then ...
    else ... // Who knows?

Além disso, pode ser tentador engolir qualquer exceção no desejo de uma função "simples" que retorna um tipo "mais agradável":

// Can be problematic due to discarding the cause of error.
let tryReadAllText (path : string) =
    try System.IO.File.ReadAllText path |> Some
    with _ -> None

Infelizmente, tryReadAllText pode gerar inúmeras exceções com base na miríade de coisas que podem acontecer em um sistema de arquivos, e esse código descarta qualquer informação sobre o que pode realmente estar dando errado no seu ambiente. Se você substituir esse código por um tipo de resultado, voltará à análise da mensagem de erro "tipada com cadeia de caracteres":

// Problematic, callers only have a string to figure the cause of error.
let tryReadAllText (path : string) =
    try System.IO.File.ReadAllText path |> Ok
    with e -> Error e.Message

let r = tryReadAllText "path-to-file"
match r with
| Ok text -> ...
| Error e ->
    if e.Contains "uh oh, here we go again..." then ...
    else ...

Além disso, colocar o próprio objeto de exceção no construtor Error apenas força você a lidar corretamente com o tipo de exceção no site de chamada em vez de na função. Fazer isso de maneira efetiva cria exceções verificadas, que são notoriamente inconvenientes de se lidar como um chamador de uma API.

Uma boa alternativa aos exemplos acima é capturar exceções específicas e retornar um valor significativo no contexto dessa exceção. Se você modificar a função tryReadAllText da seguinte maneira, None terá mais significado:

let tryReadAllTextIfPresent (path : string) =
    try System.IO.File.ReadAllText path |> Some
    with :? FileNotFoundException -> None

Em vez de funcionar como um catch-all, essa função agora tratará corretamente o caso quando um arquivo não for encontrado e atribuirá esse significado a um retorno. Esse valor retornado pode ser mapeado para esse caso de erro, sem descartar nenhuma informação contextual nem forçar os chamadores a lidar com um caso que pode não ser relevante nesse ponto do código.

Tipos como Result<'Success, 'Error> são apropriados para operações básicas em que não estão aninhados, e tipos opcionais F# são perfeitos para representar quando algo pode retornar algo ou nada. No entanto, eles não são uma substituição para exceções e não devem ser usados na tentativa de substituir as exceções. Em vez disso, devem ser aplicados de modo criterioso para abordar aspectos específicos da política de gerenciamento de exceções e erros de maneiras direcionadas.

Aplicação parcial e programação sem pontos

O F# dá suporte à aplicação parcial e, portanto, a várias maneiras de programação em um estilo sem pontos. Isso pode ser útil para a reutilização de código em um módulo ou para a implementação de algo, mas não é algo a ser exposto publicamente. Em geral, a programação sem pontos não é uma virtude em si mesma, e pode adicionar uma barreira cognitiva significativa para as pessoas que não estão imersas no estilo.

Não uso da aplicação parcial e do currying em APIs públicas

Com poucas exceções, o uso da aplicação parcial em APIs públicas pode ser confuso para os consumidores. Normalmente, os valores com limite de let no código F# são valores, não valores de função. A combinação de valores e valores de função pode resultar em salvar algumas linhas de código em troca de um pouco de sobrecarga cognitiva, especialmente se combinados com operadores como >> para compor funções.

Consideração sobre as implicações de ferramentas para a programação sem pontos

As funções via currying não rotulam os respectivos argumentos. Isso traz implicações de ferramentas. Considere as duas seguintes funções:

let func name age =
    printfn $"My name is {name} and I am %d{age} years old!"

let funcWithApplication =
    printfn "My name is %s and I am %d years old!"

Ambas são funções válidas, mas funcWithApplication é uma função via currying. Ao posicionar o cursor nos tipos em um editor, você verá o seguinte:

val func : name:string -> age:int -> unit

val funcWithApplication : (string -> int -> unit)

No site de chamada, as dicas de ferramentas, como o Visual Studio, fornecerão a assinatura de tipo, mas como não há nomes definidos, ele não exibirá nomes. Os nomes são essenciais para um bom design de API porque ajudam os chamadores a entender melhor o significado por trás da API. O uso do código sem pontos na API pública pode dificultar o entendimento dos chamadores.

Se você encontrar um código sem pontos como funcWithApplication que seja publicamente consumível, será recomendável fazer uma expansão completa de η para que as ferramentas possam captar nomes significativos para argumentos.

Além disso, a depuração do código sem pontos pode ser complexa, se não impossível. As ferramentas de depuração dependem de valores associados a nomes (por exemplo, associações let) para que você possa inspecionar os valores intermediários no meio da execução. Quando o código não tem valores para inspecionar, não há nada para depurar. No futuro, as ferramentas de depuração podem evoluir para sintetizar esses valores com base em caminhos executados anteriormente, mas não é uma boa ideia restringir suas apostas a uma possível funcionalidade de depuração.

Consideração sobre a aplicação parcial como uma técnica para reduzir os textos clichês internos

Em contraste com o ponto anterior, a aplicação parcial é uma ferramenta maravilhosa para reduzir os textos clichês em um aplicativo ou nos componentes internos mais profundos de uma API. Pode ser útil para o teste de unidade da implementação de APIs mais complicadas, em que os textos clichês geralmente são problemáticos. Por exemplo, o código a seguir mostra como você pode realizar o que a maioria das estruturas de simulação fornece sem usar uma dependência externa na estrutura nem precisar aprender a usar uma API sob medida relacionada.

Por exemplo, considere a seguinte topografia de solução:

MySolution.sln
|_/ImplementationLogic.fsproj
|_/ImplementationLogic.Tests.fsproj
|_/API.fsproj

ImplementationLogic.fsproj pode expor um código como:

module Transactions =
    let doTransaction txnContext txnType balance =
        ...

type Transactor(ctx, currentBalance) =
    member _.ExecuteTransaction(txnType) =
        Transactions.doTransaction ctx txnType currentBalance
        ...

O teste de unidade de Transactions.doTransaction em ImplementationLogic.Tests.fsproj é fácil:

namespace TransactionsTestingUtil

open Transactions

module TransactionsTestable =
    let getTestableTransactionRoutine mockContext = Transactions.doTransaction mockContext

A aplicação parcial de doTransaction com um objeto de contexto de simulação permite que você chame a função em todos os testes de unidade sem precisar construir um contexto simulado sempre:

module TransactionTests

open Xunit
open TransactionTypes
open TransactionsTestingUtil
open TransactionsTestingUtil.TransactionsTestable

let testableContext =
    { new ITransactionContext with
        member _.TheFirstMember() = ...
        member _.TheSecondMember() = ... }

let transactionRoutine = getTestableTransactionRoutine testableContext

[<Fact>]
let ``Test withdrawal transaction with 0.0 for balance``() =
    let expected = ...
    let actual = transactionRoutine TransactionType.Withdraw 0.0
    Assert.Equal(expected, actual)

Não aplique essa técnica universalmente a toda a base de código, mas essa é uma boa maneira de reduzir os textos clichês de componentes internos complicados e os testes de unidade deles.

Controle de acesso

O F# tem várias opções para controle de acesso, herdadas do que está disponível no runtime do .NET. Elas não são apenas utilizáveis para tipos – você também pode usá-las para funções.

Boas práticas no contexto de bibliotecas que são amplamente consumidas:

  • Prefira o uso de tipos e membros não public até que você precise que eles sejam consumidos publicamente. Isso também minimiza o que os consumidores fazem.
  • Esforce-se para manter todas as funcionalidades auxiliares private.
  • Considere o uso de [<AutoOpen>] em um módulo privado de funções auxiliares se elas se tornarem numerosas.

Inferência de tipos e genéricos

A inferência de tipos pode poupar você de digitar muitos textos clichês. A generalização automática no compilador F# pode ajudar você a escrever um código mais genérico quase sem nenhum esforço adicional da sua parte. No entanto, esses recursos não são universalmente bons.

  • Considere a possibilidade de rotular os nomes de argumentos com tipos explícitos em APIs públicas e não depender da inferência de tipos para isso.

    O motivo disso é que você deve estar no controle da forma da API, não do compilador. Embora o compilador possa fazer um bom trabalho ao inferir tipos para você, é possível ter a forma da API alterada se os componentes internos dos quais ele depende têm tipos alterados. Isso pode ser o que você quiser, mas quase certamente resultará em uma alteração interruptiva de API com a qual os consumidores downstream precisarão lidar. Se, em vez disso, você controlar explicitamente a forma de sua API pública, poderá controlar essas alterações interruptivas. Em termos de DDD, isso pode ser considerado como uma camada anticorrupção.

  • Considere a possibilidade de dar um nome significativo aos argumentos genéricos.

    A menos que você esteja escrevendo um código verdadeiramente genérico que não seja específico de um domínio específico, um nome significativo pode ajudar outros programadores a entender o domínio em que estão trabalhando. Por exemplo, um parâmetro de tipo chamado 'Document no contexto de interação com um banco de dados de documentos torna mais claro os tipos de documentos genéricos que podem ser aceitos pela função ou pelo membro com o qual você está trabalhando.

  • Considere a possibilidade de nomear parâmetros de tipo genérico com PascalCase.

    Essa é a maneira geral de realizar tarefas no .NET e, portanto, recomendamos que você use PascalCase em vez de snake_case ou camelCase.

Por fim, a generalização automática nem sempre é um benefício para as pessoas que não estão familiarizadas com o F# ou com uma base de código grande. Há uma sobrecarga cognitiva no uso de componentes genéricos. Além disso, se as funções generalizadas automaticamente não forem usadas com tipos de entrada diferentes (muito menos se elas forem destinadas a serem usadas assim), então não haverá nenhum benefício real em elas serem genéricas. Sempre considere se o código que você está escrevendo realmente se beneficiará de ser genérico.

Desempenho

Consideração sobre structs para tipos pequenos com altas taxas de alocação

Em geral, o uso de structs (também chamados de tipos de valor) pode resultar em um desempenho mais alto para um código, pois normalmente evita a alocação de objetos. No entanto, os structs nem sempre são um botão para "ir mais rápido": se o tamanho dos dados em um struct exceder 16 bytes, a cópia dos dados geralmente poderá resultar em mais tempo de CPU gasto do que o uso de um tipo de referência.

Para determinar se você deve usar um struct, considere as seguintes condições:

  • Se o tamanho dos dados for 16 bytes ou menor.
  • Se é provável que você tenha muitas instâncias desses tipos residentes na memória em um programa em execução.

Se a primeira condição se aplicar, você geralmente deverá usar um struct. Se ambas se aplicarem, você quase sempre deverá usar um struct. Pode haver alguns casos em que as condições anteriores se aplicam, mas o uso de um struct não é melhor nem pior do que o uso de um tipo de referência, mas é provável que eles sejam raros. No entanto, é importante sempre fazer a medição ao fazer alterações como esta, não trabalhar com suposições ou a intuição.

Consideração sobre tuplas de struct ao agrupar tipos de valores pequenos com altas taxas de alocação

Considere as duas seguintes funções:

let rec runWithTuple t offset times =
    let offsetValues x y z offset =
        (x + offset, y + offset, z + offset)

    if times <= 0 then
        t
    else
        let (x, y, z) = t
        let r = offsetValues x y z offset
        runWithTuple r offset (times - 1)

let rec runWithStructTuple t offset times =
    let offsetValues x y z offset =
        struct(x + offset, y + offset, z + offset)

    if times <= 0 then
        t
    else
        let struct(x, y, z) = t
        let r = offsetValues x y z offset
        runWithStructTuple r offset (times - 1)

Ao comparar essas funções com uma ferramenta de parâmetro de comparação estatística como o BenchmarkDotNet, você descobrirá que a função runWithStructTuple que usa tuplas de struct é executada 40% mais rapidamente e não aloca nenhuma memória.

No entanto, esses resultados nem sempre serão o caso no seu código. Se você marcar uma função como inline, o código que usa tuplas de referência poderá obter algumas otimizações adicionais ou o código alocado poderá simplesmente ser otimizado. Você sempre deve medir os resultados sempre que o desempenho estiver relacionado e nunca trabalhar com suposições ou a intuição.

Consideração sobre registros de struct quando o tipo é pequeno e tem altas taxas de alocação

A regra de ouro descrita anteriormente também é válida para tipos de registro F#. Considere os seguintes tipos de dados e funções que os processam:

type Point = { X: float; Y: float; Z: float }

[<Struct>]
type SPoint = { X: float; Y: float; Z: float }

let rec processPoint (p: Point) offset times =
    let inline offsetValues (p: Point) offset =
        { p with X = p.X + offset; Y = p.Y + offset; Z = p.Z + offset }

    if times <= 0 then
        p
    else
        let r = offsetValues p offset
        processPoint r offset (times - 1)

let rec processStructPoint (p: SPoint) offset times =
    let inline offsetValues (p: SPoint) offset =
        { p with X = p.X + offset; Y = p.Y + offset; Z = p.Z + offset }

    if times <= 0 then
        p
    else
        let r = offsetValues p offset
        processStructPoint r offset (times - 1)

Isso é semelhante ao código de tupla anterior, mas, desta vez, o exemplo usa registros e uma função interna embutida.

Ao comparar essas funções com uma ferramenta de parâmetro de comparação estatística como o BenchmarkDotNet, você verá que processStructPoint é executado quase 60% mais rapidamente e não aloca nada no heap gerenciado.

Consideração sobre uniões discriminadas de structs quando o tipo de dados é pequeno com altas taxas de alocação

As observações anteriores sobre o desempenho com registros e tuplas de struct também são válidos para uniões discriminadas em F#. Considere o seguinte código:

    type Name = Name of string

    [<Struct>]
    type SName = SName of string

    let reverseName (Name s) =
        s.ToCharArray()
        |> Array.rev
        |> System.String
        |> Name

    let structReverseName (SName s) =
        s.ToCharArray()
        |> Array.rev
        |> System.String
        |> SName

É comum definir uniões discriminadas de capitalização única como esta para a modelagem de domínio. Ao comparar essas funções com uma ferramenta de parâmetro de comparação estatística como o BenchmarkDotNet, você verá que structReverseName é executado cerca de 25% mais rapidamente do que reverseName para cadeias de caracteres pequenas. Para cadeias de caracteres grandes, ambos têm o mesmo desempenho. Portanto, nesse caso, é sempre preferível usar um struct. Como já mencionado, sempre faça a medição e não trabalhe com suposições ou a intuição.

Embora o exemplo anterior tenha mostrado que uma união discriminada de structs apresentou melhor desempenho, é comum ter uniões discriminadas maiores ao modelar um domínio. Tipos de dados maiores como esses poderão não funcionar tão bem se forem structs, dependendo das operações neles, pois mais cópias poderão estar envolvidas.

Imutabilidade e mutação

Os valores F# são imutáveis por padrão, o que permite evitar determinadas classes de bugs (especialmente aquelas que envolvem simultaneidade e paralelismo). No entanto, em alguns casos, para obter uma eficiência ideal (ou até mesmo razoável) do tempo de execução ou das alocações de memória, um intervalo de trabalho pode ser mais bem implementado usando a mutação in-loco do estado. Isso é possível com base em aceitação com o F# e com a palavra-chave mutable.

O uso de mutable em F# pode estar em desacordo com a pureza funcional. Isso é compreensível, mas a pureza funcional em todos os lugares pode estar em desacordo com as metas de desempenho. Um compromisso é encapsular a mutação de modo que os chamadores não precisem se importar com o que acontece quando chamam uma função. Isso permite que você escreva uma interface funcional em uma implementação baseada em mutação para um código crítico ao desempenho.

Além disso, os constructos de associação let do F# permitem aninhar associações umas nas outras. Isso pode ser aproveitado para manter o escopo da variável mutable com o menor valor teorético ou próximo a ele.

let data =
    [
        let mutable completed = false
        while not completed do
            logic ()
            // ...
            if someCondition then
                completed <- true   
    ]

Nenhum código pode acessar o completed mutável que foi usado apenas para inicializar o valor associado a let data.

Encapsulamento do código mutável em interfaces imutáveis

Com a transparência referencial como meta, é fundamental escrever um código que não exponha a vulnerabilidade mutável das funções críticas ao desempenho. Por exemplo, o seguinte código implementa a função Array.containsna biblioteca principal F#:

[<CompiledName("Contains")>]
let inline contains value (array:'T[]) =
    checkNonNull "array" array
    let mutable state = false
    let mutable i = 0
    while not state && i < array.Length do
        state <- value = array[i]
        i <- i + 1
    state

Chamar essa função várias vezes não altera a matriz subjacente nem exige que você mantenha qualquer estado mutável ao consumi-la. É referencialmente transparente, embora quase todas as linhas de código nela usem a mutação.

Consideração sobre o encapsulamento de dados mutáveis em classes

O exemplo anterior usou uma só função para encapsular operações usando dados mutáveis. Isso nem sempre é suficiente para conjuntos de dados mais complexos. Considere os seguintes conjuntos de funções:

open System.Collections.Generic

let addToClosureTable (key, value) (t: Dictionary<_,_>) =
    if t.ContainsKey(key) then
        t[key] <- value
    else
        t.Add(key, value)

let closureTableCount (t: Dictionary<_,_>) = t.Count

let closureTableContains (key, value) (t: Dictionary<_, HashSet<_>>) =
    match t.TryGetValue(key) with
    | (true, v) -> v.Equals(value)
    | (false, _) -> false

Esse código apresenta bom desempenho, mas expõe a estrutura de dados baseada em mutação da qual os chamadores são responsáveis pela manutenção. Isso pode ser encapsulado em uma classe sem membros subjacentes que possam ser alterados:

open System.Collections.Generic

/// The results of computing the LALR(1) closure of an LR(0) kernel
type Closure1Table() =
    let t = Dictionary<Item0, HashSet<TerminalIndex>>()

    member _.Add(key, value) =
        if t.ContainsKey(key) then
            t[key] <- value
        else
            t.Add(key, value)

    member _.Count = t.Count

    member _.Contains(key, value) =
        match t.TryGetValue(key) with
        | (true, v) -> v.Equals(value)
        | (false, _) -> false

Closure1Table encapsula a estrutura de dados baseada em mutação subjacente, não forçando os chamadores a manter a estrutura de dados subjacente. As classes são uma forma eficiente de encapsular dados e rotinas baseadas em mutação sem expor os detalhes aos chamadores.

Preferir let mutable a ref

As células de referência são uma forma de representar a referência para um valor em vez do próprio valor. Embora possam ser usados para o código crítico ao desempenho, eles não são recomendados. Considere o seguinte exemplo:

let kernels =
    let acc = ref Set.empty

    processWorkList startKernels (fun kernel ->
        if not ((!acc).Contains(kernel)) then
            acc := (!acc).Add(kernel)
        ...)

    !acc |> Seq.toList

O uso de uma célula de referência agora "polui" todo o código posterior com a necessidade de desreferenciar e referenciar novamente os dados subjacentes. Em vez disso, considere o uso de let mutable:

let kernels =
    let mutable acc = Set.empty

    processWorkList startKernels (fun kernel ->
        if not (acc.Contains(kernel)) then
            acc <- acc.Add(kernel)
        ...)

    acc |> Seq.toList

Além do único ponto de mutação no meio da expressão lambda, todos os outros códigos que passam por acc podem fazer isso de uma forma que não seja diferente do uso de um valor imutável normal com limite de let. Isso facilitará a alteração ao longo do tempo.

Valores nulos e padrão

Os nulos geralmente devem ser evitados em F#. Por padrão, os tipos declarados por F# não dão suporte ao uso do literal null e todos os valores e objetos são inicializados. No entanto, algumas APIs comuns do .NET retornam ou aceitam nulos, e alguns tipos comuns, declarados, do .NET, como matrizes e cadeias de caracteres, permitem nulos. No entanto, a ocorrência de valores null é muito rara na programação em F#, e um dos benefícios de usar F# é evitar erros de referência nula na maioria dos casos.

Evitar o uso do atributo AllowNullLiteral

Por padrão, os tipos declarados por F# não dão suporte ao uso do literal null. Você pode anotar manualmente tipos F# com AllowNullLiteral para permitir isso. No entanto, quase sempre é melhor evitar fazer isso.

Evitar o uso do atributo Unchecked.defaultof<_>

É possível gerar um valor inicializado por null ou por zero para um tipo F# usando Unchecked.defaultof<_>. Isso pode ser útil ao inicializar o armazenamento para algumas estruturas de dados, ou em algum padrão de codificação de alto desempenho ou em interoperabilidade. No entanto, o uso desse constructo deve ser evitado.

Evitar o uso do atributo DefaultValue

Por padrão, registros e objetos de F# devem ser inicializados corretamente na construção. O atributo DefaultValue pode ser usado para preencher alguns campos de objetos com um valor inicializado por null ou por zero. Esse constructo raramente é necessário e seu uso deve ser evitado.

Se você verificar entradas nulas, gere exceções na primeira oportunidade

Ao escrever novo código F#, na prática não é necessário verificar entradas nulas, a menos que você espere que esse código seja usado em C# ou em outras linguagens .NET.

Se você decidir adicionar verificações de entradas nulas, execute as verificações na primeira oportunidade e gere uma exceção. Por exemplo:

let inline checkNonNull argName arg =
    if isNull arg then
        nullArg argName

module Array =
    let contains value (array:'T[]) =
        checkNonNull "array" array
        let mutable result = false
        let mutable i = 0
        while not state && i < array.Length do
            result <- value = array[i]
            i <- i + 1
        result

Por motivos herdados, algumas funções de cadeia de caracteres no FSharp.Core ainda tratam nulos como cadeias de caracteres vazias e não falham em argumentos nulos. No entanto, não use isso como diretriz e não adote padrões de codificação que atribuam qualquer significado semântico a "nulo".

Programação de objeto

O F# tem suporte total para objetos e conceitos OO (orientados a objeto). Embora muitos conceitos OO sejam eficientes e úteis, nem todos eles são ideais para uso. As listas a seguir oferecem diretrizes sobre categorias de recursos OO em alto nível.

Considere o uso desses recursos em muitas situações:

  • Notação de ponto (x.Length)
  • Membros da Instância
  • Construtores implícitos
  • Membros estáticos
  • Notação do indexador (arr[x]), pela definição de uma propriedade Item
  • Notação de divisão (arr[x..y], arr[x..], arr[..y]) pela definição de membros GetSlice
  • Argumentos nomeados e opcionais
  • Interfaces e implementações de interface

Não tente usar esses recursos primeiro, mas aplique-os criteriosamente quando forem convenientes para resolver um problema:

  • Sobrecarga de método
  • Dados mutáveis encapsulados
  • Operadores em tipos
  • Propriedades automáticas
  • Implementação de IDisposable e IEnumerable
  • Extensões de tipo
  • Eventos
  • Estruturas
  • Delegados
  • Enumerações

De modo geral, evite esses recursos, a menos que você precise usá-los:

  • Hierarquias de tipo baseadas em herança e herança de implementação
  • Valores nulos e Unchecked.defaultof<_>

Preferência de composição em vez de herança

A composição em detrimento da herança é uma expressão de longa data que um bom código F# pode seguir. O princípio fundamental é que você não deve expor uma classe base e forçar os chamadores a herdar dessa classe base para obter uma funcionalidade.

Uso de expressões de objeto para implementar interfaces se uma classe não for necessária

As expressões de objeto permitem implementar interfaces em tempo real, associando a interface implementada a um valor sem precisar fazer isso em uma classe. Isso é conveniente, especialmente se você precisa implementar a interface e não precisa ter uma classe completa.

Por exemplo, este é o código executado no Ionide para fornecer uma ação de correção de código se você adicionou um símbolo para o qual não tem uma instrução open:

    let private createProvider () =
        { new CodeActionProvider with
            member this.provideCodeActions(doc, range, context, ct) =
                let diagnostics = context.diagnostics
                let diagnostic = diagnostics |> Seq.tryFind (fun d -> d.message.Contains "Unused open statement")
                let res =
                    match diagnostic with
                    | None -> [||]
                    | Some d ->
                        let line = doc.lineAt d.range.start.line
                        let cmd = createEmpty<Command>
                        cmd.title <- "Remove unused open"
                        cmd.command <- "fsharp.unusedOpenFix"
                        cmd.arguments <- Some ([| doc |> unbox; line.range |> unbox; |] |> ResizeArray)
                        [|cmd |]
                res
                |> ResizeArray
                |> U2.Case1
        }

Como não há necessidade de uma classe ao interagir com a API do Visual Studio Code, as expressões de objeto são uma ferramenta ideal para fazer isso. Elas também são úteis para testes de unidade, quando você deseja eliminar uma interface com rotinas de teste de maneira improvisada.

Consideração sobre abreviações de tipo para encurtar as assinaturas

As abreviações de tipo são uma forma conveniente de atribuir um rótulo a outro tipo, como uma assinatura de função ou um tipo mais complexo. Por exemplo, o seguinte alias atribui um rótulo ao que é necessário para definir uma computação com a CNTK, uma biblioteca de aprendizado profundo:

open CNTK

// DeviceDescriptor, Variable, and Function all come from CNTK
type Computation = DeviceDescriptor -> Variable -> Function

O nome Computation é uma forma conveniente de indicar qualquer função que corresponda à assinatura da qual está criando um alias. O uso de abreviações de tipo como essa é conveniente e permite um código mais sucinto.

Evitar o uso de abreviações de tipo para representar o domínio

Embora as abreviações de tipo sejam convenientes para dar um nome às assinaturas de função, elas podem ser confusas ao abreviar outros tipos. Considere esta abreviação:

// Does not actually abstract integers.
type BufferSize = int

Isso pode ser confuso de várias maneiras:

  • BufferSize não é uma abstração; é apenas outro nome para um inteiro.
  • Se BufferSize for exposto em uma API pública, ele poderá facilmente ser mal interpretado para significar mais do que apenas int. Geralmente, os tipos de domínio têm vários atributos e não são tipos primitivos como int. Essa abreviação viola essa suposição.
  • O uso de maiúsculas e minúsculas em BufferSize (PascalCase) implica que esse tipo contém mais dados.
  • Esse alias não oferece maior clareza em comparação com o fornecimento de um argumento nomeado para uma função.
  • A abreviação não se manifestará na IL compilada. Ela é apenas um inteiro, e esse alias é um constructo de tempo de compilação.
module Networking =
    ...
    let send data (bufferSize: int) = ...

Em resumo, a armadilha nas abreviações de tipo é que elas não são abstrações dos tipos que estão abreviando. No exemplo anterior, BufferSize é apenas um int camuflado, sem dados extras nem benefícios do sistema de tipos além do que int já tem.

Uma abordagem alternativa para o uso de abreviações de tipo para representar um domínio é usar uniões discriminadas de capitalização única. O exemplo anterior pode ser modelado da seguinte maneira:

type BufferSize = BufferSize of int

Se você escrever um código que opera em termos de BufferSize e do valor subjacente, precisará construir um em vez de transmitir qualquer inteiro arbitrário:

module Networking =
    ...
    let send data (BufferSize size) =
    ...

Isso reduz a probabilidade de transmitir erroneamente um inteiro arbitrário para a função send, pois o chamador precisa construir um tipo BufferSize para encapsular um valor antes de chamar a função.