Convenções de codificação F#

As convenções a seguir são formuladas a partir da experiência de trabalhar 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.

Organizando o código

O F# apresenta duas maneiras principais de organizar o código: módulos e namespaces. Estes 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. Os módulos não podem.
  • Os módulos podem ser decorados com [<RequireQualifiedAccess>] e [<AutoOpen>].

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

Prefira namespaces no nível superior

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

// Recommended.
namespace MyCode

type MyClass() =
    ...

Usar um módulo de nível superior pode não parecer diferente quando chamado apenas de F#, mas para consumidores de C#, os chamadores podem ser surpreendidos por terem que se qualificar MyClass com o MyCode módulo quando não estão cientes da construção C# específica using static .

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

type MyClass() =
    ...

Aplique cuidadosamente [<AutoOpen>]

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

No entanto, é uma conveniência se você tiver 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 que você separe claramente os detalhes de implementação da API pública de uma função sem ter que qualificar totalmente um auxiliar cada vez que você chamá-lo.

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

Use [<RequireQualifiedAccess>] sempre que os nomes possam entrar em conflito ou achar que isso ajuda na legibilidade

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

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

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

...

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

Ordenar open instruções topologicamente

Em F#, a ordem das declarações é importante, inclusive com open as declarações (e open type, apenas referenciadas mais open abaixo). Isso é diferente do C#, onde 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 as instruções de reordenação open podem alterar o significado do código. Como resultado, qualquer classificação arbitrária de todas as open instruções (por exemplo, alfanumericamente) 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, ordene suas open declarações na ordem em que as camadas do seu sistema são definidas. Fazer a classificação alfanumérica dentro de diferentes camadas topológicas também pode ser considerado.

Como exemplo, aqui está 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 alfanumericamente em seguida. Isso organiza o código de forma limpa sem sombrear valores acidentalmente.

Usar classes para conter valores com efeitos colaterais

Há muitas vezes em que inicializar um valor pode ter efeitos colaterais, como instanciar 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 subsequentes:

// 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

Isto é frequentemente problemático por algumas razões:

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

Em segundo lugar, os dados inicializados estaticamente não devem incluir valores que não sejam seguros para threads se o próprio componente usar vários threads. Isto é claramente violado pela dep3.

Finalmente, 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 do valor let-bound nesse módulo, ele se manifesta como um TypeInitializationException que é armazenado em cache durante todo o tempo de vida do aplicativo. Isto pode ser difícil de diagnosticar. Geralmente há uma exceção interna sobre a qual você pode tentar raciocinar, mas se não houver, então não há como dizer qual é a causa raiz.

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

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

Isso permite o seguinte:

  1. Empurrando qualquer estado dependente para fora da própria API.
  2. A configuração agora pode ser feita fora da API.
  3. Não é provável que erros na inicialização de valores dependentes se manifestem como um TypeInitializationExceptionarquivo .
  4. A API agora é mais fácil de testar.

Gestão de erros

O gerenciamento de erros em sistemas grandes é um empreendimento complexo e cheio de nuances, e não há balas de prata em garantir que seus sistemas sejam tolerantes a falhas e se comportem bem. As diretrizes a seguir devem oferecer orientação para navegar neste espaço difícil.

Representar casos de erro e estado ilegal em tipos intrínsecos ao seu domínio

Com Uniões Discriminadas, o F# oferece a capacidade de representar o estado defeituoso do programa em seu sistema de tipos. Por exemplo:

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

Neste caso, existem três formas conhecidas de levantar dinheiro de uma conta bancária que pode falhar. Cada caso de erro é representado no tipo e, portanto, pode ser tratado com segurança durante 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 em seu domínio, o código de tratamento de erros não será mais tratado como algo com o qual você deve lidar, além do fluxo regular do programa. É simplesmente uma parte do fluxo normal do programa, e não é considerado excecional. Há dois benefícios principais nisso:

  1. É mais fácil de manter à medida que o seu domínio muda ao longo do tempo.
  2. Os casos de erro são mais fáceis de testar em unidade.

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

Nem todos os erros podem ser representados em um domínio problemático. Esses tipos de falhas são de natureza excecional , daí a capacidade de criar e capturar exceções em F#.

Primeiro, é recomendável que você leia as Diretrizes de Design de Exceção. Estes também são aplicáveis ao F#.

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

Function Sintaxe Propósito
nullArg nullArg "argumentName" Gera um System.ArgumentNullException com o nome do argumento especificado.
invalidArg invalidArg "argumentName" "message" Gera um System.ArgumentException com um nome de argumento e uma mensagem especificados.
invalidOp invalidOp "message" Gera um System.InvalidOperationException com a mensagem especificada.
raise raise (ExceptionType("message")) Mecanismo de uso geral para lançar exceções.
failwith failwith "message" Gera um System.Exception com a mensagem especificada.
failwithf failwithf "format string" argForFormatString Gera um System.Exception com uma mensagem determinada pela cadeia de caracteres de formato e suas entradas.

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

As failwith funções e failwithf devem geralmente ser evitadas porque elevam o tipo de base Exception , não uma exceção específica. De acordo com as Diretrizes de Design de Exceção, você deseja gerar exceções mais específicas quando puder.

Usar sintaxe de tratamento de exceções

F# suporta padrões de exceção através da try...with sintaxe:

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 para executar em face de uma exceção com a correspondência de padrões pode ser um pouco complicado se você deseja manter o código limpo. Uma maneira 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 estar consumindo uma API que, quando lança uma exceção, inclui informações valiosas nos metadados da exceção. Desembrulhar um valor útil no corpo da exceção capturada dentro do Padrão Ativo e retornar esse valor pode ser útil em algumas situações.

Não use o tratamento de erros monádicos para substituir exceções

As exceções são muitas vezes vistas como tabu no paradigma funcional puro. De fato, as exceções violam a pureza, por isso é seguro considerá-las não muito funcionalmente puras. No entanto, isso ignora a realidade de onde o código deve ser executado e que erros de tempo de execução podem ocorrer. Em geral, escreva código no pressuposto de que a maioria das coisas não é pura ou total, para minimizar surpresas desagradáveis (como esvaziar catch em C# ou gerenciar mal o rastreamento de pilha, descartando informações).

É importante considerar os seguintes pontos fortes/aspetos centrais das Exceções em relação à sua relevância e adequação no tempo de execução do .NET e no ecossistema entre linguagens como um todo:

  • Eles contêm informações de diagnóstico detalhadas, o que é útil ao depurar um problema.
  • Eles são bem compreendidos pelo tempo de execução e outras linguagens .NET.
  • Eles podem reduzir clichês significativos quando comparados com o código que sai de seu caminho para evitar exceções, implementando algum subconjunto de sua semântica em uma base ad-hoc.

Este terceiro ponto é fundamental. Para operações complexas não triviais, deixar de usar exceções pode resultar em lidar com estruturas como esta:

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

O que pode facilmente levar a códigos frágeis como correspondência de padrões em erros "stringly typed":

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 lançar 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 em seu ambiente. Se você substituir esse código por um tipo de resultado, estará de volta à análise de mensagem de erro "stringly typed":

// 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 ...

E colocar o próprio objeto de exceção no Error construtor 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 efetivamente cria exceções verificadas, que são notoriamente pouco divertidas de 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 tryReadAllText função da seguinte forma, None tem mais significado:

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

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

Tipos como Result<'Success, 'Error> são apropriados para operações básicas onde não estão aninhados, e tipos opcionais de F# são perfeitos para representar quando algo pode retornar algo ou nada. No entanto, não substituem as exceções e não devem ser utilizados numa tentativa de substituir as exceções. Em vez disso, devem ser aplicadas de forma criteriosa para abordar aspetos específicos da política de gestão de exceções e erros de forma direcionada.

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

F# suporta aplicação parcial e, portanto, várias maneiras de programar em um estilo livre de pontos. Isso pode ser benéfico para a reutilização de código dentro de um módulo ou a implementação de algo, mas não é algo para expor publicamente. Em geral, a programação sem pontos não é uma virtude por si só, e pode adicionar uma barreira cognitiva significativa para pessoas que não estão imersas no estilo.

Não use aplicativos parciais e currying em APIs públicas

Com poucas exceções, o uso de aplicação parcial em APIs públicas pode ser confuso para os consumidores. Normalmente, letos valores -bound no código F# são valores, não valores de função. Misturar 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 combinado com operadores como >> para compor funções.

Considere as implicações das ferramentas para a programação sem pontos

As funções curried não rotulam seus argumentos. Isso tem implicações em ferramentas. Considere as duas funções a seguir:

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 curried. Quando você passa o mouse sobre seus tipos em um editor, você vê o seguinte:

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

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

No site de chamada, as dicas de ferramentas em ferramentas como o Visual Studio fornecerão a assinatura de tipo, mas como não há nomes definidos, ela 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 de código sem pontos na API pública pode dificultar a compreensão dos chamadores.

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

Além disso, depurar código sem pontos pode ser desafiador, se não impossível. As ferramentas de depuração dependem de valores vinculados a nomes (por exemplo, let associações) para que você possa inspecionar valores intermediários no meio da execução. Quando seu 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 proteger suas apostas em possíveis funcionalidades de depuração.

Considerar a aplicação parcial como uma técnica para reduzir o clichê interno

Em contraste com o ponto anterior, a aplicação parcial é uma ferramenta maravilhosa para reduzir o clichê dentro de um aplicativo ou os internos mais profundos de uma API. Pode ser útil para testes de unidade a implementação de APIs mais complicadas, onde o clichê é muitas vezes uma dor de cabeça para lidar. Por exemplo, o código a seguir mostra como você pode realizar o que a maioria das estruturas simuladas lhe dá sem ter uma dependência externa de tal estrutura e ter que aprender 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 códigos como:

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

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

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

namespace TransactionsTestingUtil

open Transactions

module TransactionsTestable =
    let getTestableTransactionRoutine mockContext = Transactions.doTransaction mockContext

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

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 sua base de código, mas é uma boa maneira de reduzir o clichê para internos complicados e testar esses internos.

Controlo de acesso

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

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

  • Prefira não-tipospublic e membros até precisar que eles sejam publicamente consumíveis. Isso também minimiza o que os consumidores associam.
  • Esforce-se para manter todas as funcionalidades privateauxiliares.
  • Considere o uso de [<AutoOpen>] um módulo privado de funções auxiliares se elas se tornarem numerosas.

Inferência de tipo e genéricos

A inferência de tipo pode salvá-lo de digitar um monte de clichê. E a generalização automática no compilador F# pode ajudá-lo a escrever código mais genérico com quase nenhum esforço extra da sua parte. No entanto, essas características não são universalmente boas.

  • Considere rotular nomes de argumento com tipos explícitos em APIs públicas e não confie na inferência de tipo para isso.

    A razão para isso é que você deve estar no controle da forma da sua API, não do compilador. Embora o compilador possa fazer um bom trabalho em inferir tipos para você, é possível ter a forma da sua API alterada se os internos nos quais ele se baseia tiverem mudado de tipo. Isso pode ser o que você quer, mas quase certamente resultará em uma mudança de API que os consumidores a jusante terão que lidar. Em vez disso, se você controlar explicitamente a forma de sua API pública, poderá controlar essas alterações de quebra. Em termos de DDD, isso pode ser pensado como uma camada anticorrupção.

  • Considere dar um nome significativo aos seus argumentos genéricos.

    A menos que você esteja escrevendo um código verdadeiramente genérico que não seja específico para 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 type nomeado 'Document no contexto da interação com um banco de dados de documentos deixa mais claro que tipos de documentos genéricos podem ser aceitos pela função ou membro com quem você está trabalhando.

  • Considere nomear parâmetros de tipo genéricos com PascalCase.

    Esta é a maneira geral de fazer coisas no .NET, por isso é recomendável que você use PascalCase em vez de snake_case ou camelCase.

Finalmente, a generalização automática nem sempre é uma vantagem para as pessoas que são novas no F# ou em uma grande base de código. Há sobrecarga cognitiva no uso de componentes que são genéricos. Além disso, se as funções automaticamente generalizadas não são usadas com diferentes tipos de entrada (muito menos se elas se destinam a ser usadas como tal), então não há 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

Considere estruturas para tipos pequenos com altas taxas de alocação

O uso de structs (também chamados de Tipos de Valor) geralmente pode resultar em maior desempenho para alguns códigos, pois normalmente evita a alocação de objetos. No entanto, structs nem sempre são um botão "ir mais rápido": se o tamanho dos dados em um struct exceder 16 bytes, copiar os dados pode resultar em mais tempo de CPU gasto do que usar um tipo de referência.

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

  • Se o tamanho dos seus dados for de 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 deve usar um struct. Se ambos se aplicarem, você quase sempre deve usar uma estrutura. Pode haver alguns casos em que as condições anteriores se aplicam, mas usar um struct não é melhor ou pior do que usar um tipo de referência, mas é provável que sejam raros. No entanto, é importante sempre medir ao fazer mudanças como essa, e não operar com base em suposições ou intuições.

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

Considere as duas funções a seguir:

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)

Quando você avalia essas funções com uma ferramenta de benchmarking estatístico como BenchmarkDotNet, você descobrirá que a função que usa tuplas struct é executada runWithStructTuple 40% mais rápido e não aloca memória.

No entanto, esses resultados nem sempre serão o caso em seu próprio código. Se você marcar uma função como inline, o código que usa tuplas de referência pode obter algumas otimizações adicionais, ou o código que alocaria pode simplesmente ser otimizado. Você deve sempre medir os resultados sempre que o desempenho estiver em causa e nunca operar com base em suposições ou intuições.

Considere registros struct quando o tipo for pequeno e tiver altas taxas de alocação

A regra geral descrita anteriormente também vale para os 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.

Quando você avalia essas funções com uma ferramenta de benchmarking estatístico como o BenchmarkDotNet, você descobrirá que processStructPoint é executado quase 60% mais rápido e não aloca nada no heap gerenciado.

Considere estruturar uniões discriminadas quando o tipo de dados for pequeno com altas taxas de alocação

As observações anteriores sobre o desempenho com tuplas de estrutura e recordes também valem para F# Sindicatos Discriminados. 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 caso único como esta para modelagem de domínio. Quando você avalia essas funções com uma ferramenta de benchmarking estatístico como o BenchmarkDotNet, você descobrirá que é executado cerca de 25% mais rápido do que structReverseNamereverseName para pequenas strings. Para cadeias de caracteres grandes, ambos executam aproximadamente o mesmo. Então, neste caso, é sempre preferível usar uma estrutura. Como mencionado anteriormente, meça sempre e não opere com base em suposições ou intuição.

Embora o exemplo anterior tenha mostrado que uma União Discriminada struct produziu melhor desempenho, é comum haver Uniões Discriminadas maiores ao modelar um domínio. Tipos de dados maiores como esse podem não funcionar tão bem se forem structs dependendo das operações neles, já que mais cópias podem estar envolvidas.

Imutabilidade e mutação

Os valores F# são imutáveis por padrão, o que permite evitar certas classes de bugs (especialmente aqueles que envolvem simultaneidade e paralelismo). No entanto, em certos casos, a fim de alcançar a eficiência ideal (ou mesmo razoável) do tempo de execução ou alocações de memória, uma extensão de trabalho pode ser melhor implementada usando a mutação in-loco de estado. Isso é possível em uma base opt-in com F# com a mutable palavra-chave.

O uso de mutable em F# pode parecer em desacordo com a pureza funcional. Isso é compreensível, mas a pureza funcional em todos os lugares pode estar em desacordo com os objetivos de desempenho. Um compromisso é encapsular a mutação de tal forma que os chamadores não precisam se preocupar com o que acontece quando chamam uma função. Isso permite que você escreva uma interface funcional sobre uma implementação baseada em mutação para código crítico de desempenho.

Além disso, as construções de vinculação F# let permitem aninhar ligações em outra, isso pode ser aproveitado para manter o escopo da mutable variável próximo ou em sua menor teoria.

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

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

Encapsular código mutável em interfaces imutáveis

Com a transparência referencial como objetivo, é fundamental escrever código que não exponha o submundo mutável das funções críticas de desempenho. Por exemplo, o código a seguir implementa a Array.contains função na biblioteca principal do 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 dentro dele usem mutação.

Considere encapsular dados mutáveis em classes

O exemplo anterior usava uma única função para encapsular operações usando dados mutáveis. Isto 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 tem desempenho, mas expõe a estrutura de dados baseada em mutação que os chamadores são responsáveis por manter. Isso pode ser encapsulado dentro de uma classe sem membros subjacentes que podem mudar:

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 assim os chamadores a manter a estrutura de dados subjacente. As classes são uma maneira poderosa de encapsular dados e rotinas baseadas em mutações sem expor os detalhes aos chamadores.

Prefira let mutableref

As células de referência são uma forma de representar a referência a um valor em vez do valor em si. Embora possam ser usados para código crítico de 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

A utilização de uma célula de referência "polui" agora todo o código subsequente, tendo de anular a referência e voltar a referenciar os dados subjacentes. Em vez disso, considere 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

Com exceção do único ponto de mutação no meio da expressão lambda, todos os outros códigos que tocam acc podem fazê-lo de uma maneira que não é diferente do uso de um valor imutável ligado ao normal let. Isso facilitará a mudança ao longo do tempo.

Nulos e valores padrão

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

Evite o uso do AllowNullLiteral atributo

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

Evite o uso do Unchecked.defaultof<_> atributo

É possível gerar um null valor inicializado zero ou 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 na interoperabilidade. No entanto, o uso deste constructo deve ser evitado.

Evite o uso do DefaultValue atributo

Por padrão, os registros e objetos F# devem ser inicializados corretamente na construção. O DefaultValue atributo pode ser usado para preencher alguns campos de objetos com um null valor inicializado zero ou zero. Esta construção raramente é necessária e a sua utilização deve ser evitada.

Se você verificar se há entradas nulas, levante exceções na primeira oportunidade

Ao escrever um novo código F#, na prática, não há necessidade de verificar entradas nulas, a menos que você espere que esse código seja usado a partir de C# ou outras linguagens .NET.

Se você decidir adicionar verificações para 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 tome isso como orientação e não adote padrões de codificação que atribuam qualquer significado semântico a "nulo".

Programação de objetos

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

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

  • Notação de pontos (x.Length)
  • Membros da instância
  • Construtores implícitos
  • Membros estáticos
  • Notação do indexador (arr[x]), definindo uma Item propriedade
  • Notação de fatiamento (arr[x..y], arr[x..], arr[..y]), definindo GetSlice membros
  • Argumentos nomeados e opcionais
  • Interfaces e implementações de interfaces

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

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

Geralmente evite esses recursos, a menos que você deva usá-los:

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

Prefira a composição à herança

Composição sobre herança é uma expressão de longa data que um bom código F# pode aderir. 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 funcionalidade.

Use expressões de objeto para implementar interfaces se você não precisar de uma classe

As expressões de objeto permitem que você implemente interfaces em tempo real, vinculando a interface implementada a um valor sem a necessidade de fazê-lo dentro de uma classe. Isso é conveniente, especialmente se você precisa implementar a interface e não tem necessidade de uma classe completa.

Por exemplo, aqui está o código que é 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 open instrução:

    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 de código do Visual Studio, as expressões de objeto são uma ferramenta ideal para isso. Eles também são valiosos para testes de unidade, quando você deseja criar uma interface com rotinas de teste de maneira improvisada.

Considere abreviaturas de tipo para encurtar assinaturas

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

open CNTK

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

O Computation nome é uma maneira conveniente de denotar qualquer função que corresponda à assinatura que está aliando. Usar abreviaturas de tipo como esta é conveniente e permite um código mais sucinto.

Evite usar abreviaturas de tipo para representar seu domínio

Embora as abreviaturas de tipo sejam convenientes para dar um nome a assinaturas de função, elas podem ser confusas ao abreviar outros tipos. Considere esta abreviatura:

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

Isso pode ser confuso de várias maneiras:

  • BufferSize não é uma abstração; é apenas mais um nome para um inteiro.
  • Se BufferSize for exposto em uma API pública, ele pode ser facilmente interpretado erroneamente para significar mais do que apenas int. Geralmente, os tipos de domínio têm vários atributos para eles e não são tipos primitivos como int. Esta abreviatura viola esse pressuposto.
  • O invólucro de BufferSize (PascalCase) implica que este 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 abreviatura não se manifestará em IL compilado; é apenas um inteiro e este alias é uma construção em tempo de compilação.
module Networking =
    ...
    let send data (bufferSize: int) = ...

Em resumo, a armadilha com as abreviaturas de tipo é que elas não são abstrações sobre os tipos que estão abreviando. No exemplo anterior, BufferSize é apenas um int debaixo das cobertas, sem dados extras, nem quaisquer benefícios do sistema de tipo além do que int já tem.

Uma abordagem alternativa ao uso de abreviaturas de tipo para representar um domínio é usar uniões discriminadas de caso único. A amostra anterior pode ser modelada da seguinte forma:

type BufferSize = BufferSize of int

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

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

Isso reduz a probabilidade de passar por engano um inteiro arbitrário para a send função, porque o chamador deve construir um BufferSize tipo para encapsular um valor antes de chamar a função.