Convenciones de código de F#

Las convenciones siguientes se formulan a partir de la experiencia de trabajo con grandes bases de código de F#. Los cinco principios del código F# correcto son la base de cada recomendación. Están relacionadas con las directrices de diseño de componentes de F#,pero son aplicables a cualquier código de F#, no solo a componentes como bibliotecas.

Organización del código

F# ofrece dos formas principales de organizar el código: módulos y espacios de nombres. Son similares, pero tienen las siguientes diferencias:

  • Los espacios de nombres se compilan como espacios de nombres de .NET. Los módulos se compilan como clases estáticas.
  • Los espacios de nombres siempre son de nivel superior. Los módulos pueden ser de nivel superior y estar anidados dentro de otros módulos.
  • Los espacios de nombres pueden abarcar varios archivos. Los módulos no pueden.
  • Los módulos se pueden decorar [<RequireQualifiedAccess>] con y [<AutoOpen>] .

Las siguientes directrices le ayudarán a usarlos para organizar el código.

Preferir espacios de nombres en el nivel superior

Para cualquier código que se consuma públicamente, los espacios de nombres son preferibles a los módulos del nivel superior. Dado que se compilan como espacios de nombres de .NET, se pueden consumir desde C# sin ningún problema.

// Good!
namespace MyCode

type MyClass() =
    ...

Puede que el uso de un módulo de nivel superior no parezca diferente cuando se llama solo desde F#, pero para los consumidores de C#, es posible que los autores de la llamada se despreoprendan al tener que calificar MyClass con el MyCode módulo.

// Bad!
module MyCode

type MyClass() =
    ...

Aplicar cuidadosamente [<AutoOpen>]

La construcción puede construir el ámbito de lo que está disponible para los autores de la llamada y la respuesta a de dónde procede algo [<AutoOpen>] es "mágica". Esto no es algo bueno. Una excepción a esta regla es la propia biblioteca de F# Core (aunque este hecho también es un poco difícil).

Sin embargo, es conveniente si tiene funcionalidad auxiliar para una API pública que desea organizar por separado de esa 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

Esto le permite separar limpiamente los detalles de implementación de la API pública de una función sin tener que calificar completamente un asistente cada vez que lo llame.

Además, la exposición de métodos de extensión y generadores de expresiones en el nivel de espacio de nombres se puede expresar perfectamente con [<AutoOpen>] .

Use [<RequireQualifiedAccess>] siempre que los nombres puedan estar en conflicto o cree que ayuda a mejorar la legibilidad.

Agregar el atributo a un módulo indica que es posible que no se abra el módulo y que las referencias a los elementos del módulo requieren [<RequireQualifiedAccess>] acceso calificado explícito. Por ejemplo, el Microsoft.FSharp.Collections.List módulo tiene este atributo.

Esto resulta útil cuando las funciones y los valores del módulo tienen nombres que probablemente entren en conflicto con los nombres de otros módulos. Requerir acceso calificado puede aumentar considerablemente el mantenimiento a largo plazo y la capacidad de evolución de una biblioteca.

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

...

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

Ordenar open instrucciones topológicamente

En F#, el orden de las declaraciones es importante, incluidas open las instrucciones . Esto es diferente de C#, donde el efecto de y es independiente del orden using de esas instrucciones en un using static archivo.

En F#, los elementos abiertos en un ámbito pueden sombrar a otros que ya están presentes. Esto significa que las instrucciones de open reordenación podrían modificar el significado del código. Como resultado, no se recomienda cualquier ordenación arbitraria de todas las instrucciones open (por ejemplo, alfanuméricamente), para no generar un comportamiento diferente que se pueda esperar.

En su lugar, se recomienda ordenarlos topológicamente; es decir, ordene open las instrucciones en el orden en que se definen las capas del sistema. También se puede tener en cuenta la ordenación alfanumérica dentro de diferentes capas topológicas.

Por ejemplo, esta es la ordenación topológica para el archivo de API pública del servicio de compilador de 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

Un salto de línea separa las capas topológicas y, posteriormente, cada capa se ordena alfanuméricamente. Esto organiza correctamente el código sin que se sombreen accidentalmente los valores.

Usar clases para contener valores que tienen efectos secundarios

Hay muchas veces en las que inicializar un valor puede tener efectos secundarios, como crear instancias de un contexto en una base de datos u otro recurso remoto. Es tentador inicializar tales cosas en un módulo y usarla en funciones posteriores:

// This is bad!
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 = doSutffWith dep1 dep2 dep3 arg

Esto suele ser una mala idea por algunas razones:

En primer lugar, la configuración de la aplicación se inserta en el código base con dep1 y dep2 . Esto es difícil de mantener en bases de código más grandes.

En segundo lugar, los datos inicializados estáticamente no deben incluir valores que no sean seguros para subprocesos si el propio componente usará varios subprocesos. Esto es claramente infringido por dep3 .

Por último, la inicialización del módulo se compila en un constructor estático para toda la unidad de compilación. Si se produce algún error en la inicialización de valores enlazados let en ese módulo, se manifiesta como un que se almacena en caché durante toda la duración TypeInitializationException de la aplicación. Esto puede ser difícil de diagnosticar. Normalmente, hay una excepción interna sobre la que se puede intentar razonar, pero si no lo hay, no se sabe cuál es la causa principal.

En su lugar, use una clase simple para contener dependencias:

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

Esto permite lo siguiente:

  1. Insertar cualquier estado dependiente fuera de la propia API.
  2. La configuración ahora se puede realizar fuera de la API.
  3. No es probable que los errores de inicialización de valores dependientes se manifestan como TypeInitializationException .
  4. La API ahora es más fácil de probar.

Administración de errores

La administración de errores en sistemas grandes es un esfuerzo complejo y con matices, y no hay ninguna viñeta de plata para garantizar que los sistemas sean tolerantes a errores y se comporten bien. Las siguientes directrices deben ofrecer instrucciones para navegar por este difícil espacio.

Representar casos de error y estado no admitido en tipos intrínsecos al dominio

Con uniones discriminadas,F# ofrece la capacidad de representar el estado de programa defectuoso en el sistema de tipos. Por ejemplo:

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

En este caso, hay tres maneras conocidas de que la retirada de dinero de una cuenta bancaria puede producir un error. Cada caso de error se representa en el tipo y, por tanto, se puede tratar de forma segura en todo el 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"

En general, si puede modelar las distintas formas en que se puede producir un error en el dominio, el código de control de errores ya no se trata como algo con lo que debe tratar además del flujo de programa normal. Es simplemente una parte del flujo de programa normal y no se considera excepcional. Esto tiene dos ventajas principales:

  1. Es más fácil de mantener a medida que el dominio cambia con el tiempo.
  2. Los casos de error son más fáciles de realizar pruebas unitarias.

Usar excepciones cuando los errores no se pueden representar con tipos

No todos los errores se pueden representar en un dominio con problemas. Estos tipos de errores son excepcionales por naturaleza, de ahí la capacidad de generar y detectar excepciones en F#.

En primer lugar, se recomienda leer las instrucciones de diseño de excepciones. También son aplicables a F#.

Las construcciones principales disponibles en F# para generar excepciones deben tenerse en cuenta en el orden de preferencia siguiente:

Función Sintaxis Propósito
nullArg nullArg "argumentName" Genera un objeto System.ArgumentNullException con el nombre de argumento especificado.
invalidArg invalidArg "argumentName" "message" Genera un objeto System.ArgumentException con un nombre de argumento y un mensaje especificados.
invalidOp invalidOp "message" Genera un System.InvalidOperationException objeto con el mensaje especificado.
raise raise (ExceptionType("message")) Mecanismo de uso general para iniciar excepciones.
failwith failwith "message" Genera un System.Exception objeto con el mensaje especificado.
failwithf failwithf "format string" argForFormatString Genera un System.Exception objeto con un mensaje determinado por la cadena de formato y sus entradas.

Use nullArg , y como mecanismo para iniciar , y cuando invalidArg invalidOp ArgumentNullException ArgumentException InvalidOperationException corresponda.

Por lo general, se deben evitar las funciones y failwith porque genera el tipo failwithf Exception base, no una excepción específica. Según las directrices de diseño de excepciones,quiere generar excepciones más específicas cuando pueda.

Uso de la sintaxis de control de excepciones

F# admite patrones de excepción a través de la try...with sintaxis :

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

La funcionalidad de conciliación que se realiza en caso de una excepción con la coincidencia de patrones puede ser un poco complicada si desea mantener el código limpio. Una manera de controlar esto es usar patrones activos como medio para agrupar la funcionalidad que rodea un caso de error con una excepción propia. Por ejemplo, puede que esté consumiendo una API que, cuando produce una excepción, incluye información valiosa en los metadatos de excepción. Desencapsular un valor útil en el cuerpo de la excepción capturada dentro del patrón activo y devolver ese valor puede resultar útil en algunas situaciones.

No use el control de errores de la biblioteca para reemplazar las excepciones.

Las excepciones se suelen ver como tabú en la programación funcional. De hecho, las excepciones infringen la puridad, por lo que es seguro considerarlas no muy funcionales. Sin embargo, esto omite la realidad de dónde debe ejecutarse el código y que pueden producirse errores en tiempo de ejecución. En general, escriba código en la suposición de que la mayoría de las cosas no son puras ni totales, para minimizar las sorpresas inesperadas.

Es importante tener en cuenta los siguientes aspectos básicos de las excepciones con respecto a su relevancia y su utilidad en el entorno de ejecución de .NET y en el ecosistema entre lenguajes en su conjunto:

  • Contienen información de diagnóstico detallada, que resulta útil al depurar un problema.
  • El entorno de ejecución y otros lenguajes de .NET los comprenden bien.
  • Pueden reducir considerablemente la semántica cuando se comparan con el código que se sale de su camino para evitar excepciones mediante la implementación de algún subconjunto de su semántica de forma ad hoc.

Este tercer punto es crítico. En el caso de las operaciones complejas no complejas, el hecho de no usar excepciones puede dar lugar a estructuras como esta:

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

Lo que puede dar lugar fácilmente a código frágil, como la coincidencia de patrones en errores de tipo "con tipo de cadena":

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?

Además, puede ser tentador ingerir cualquier excepción en el deseo de una función "simple" que devuelva un tipo "más bueno":

// This is bad!
let tryReadAllText (path : string) =
    try System.IO.File.ReadAllText path |> Some
    with _ -> None

Desafortunadamente, puede producir numerosas excepciones basadas en la infinidad de cosas que pueden ocurrir en un sistema de archivos y este código descarta cualquier información sobre lo que podría estar yendo mal en su tryReadAllText entorno. Si reemplaza este código por un tipo de resultado, vuelve al análisis de mensajes de error "con tipo de cadena":

// This is bad!
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 ...

Y colocar el propio objeto de excepción en el constructor simplemente obliga a tratar correctamente el tipo de excepción en el sitio de llamada en Error lugar de en la función . Al hacerlo, se crean excepciones comprobadas, que no se pueden tratar como llamadores de una API.

Una buena alternativa a los ejemplos anteriores es detectar excepciones específicas y devolver un valor significativo en el contexto de esa excepción. Si modifica la función tryReadAllText como se muestra a continuación, tiene más None significado:

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

En lugar de funcionar como un catch-all, esta función ahora controlará correctamente el caso cuando no se encontró un archivo y asignará ese significado a una devolución. Este valor devuelto se puede asignar a ese caso de error, sin descartar ninguna información contextual ni forzar a los llamadores a tratar con un caso que puede no ser relevante en ese momento del código.

Los tipos como son adecuados para las operaciones básicas en las que no están anidados y los tipos opcionales de F# son perfectos para representar cuándo algo podría devolver Result<'Success, 'Error> algo o nada. No obstante, no sustituyen a las excepciones y no deben usarse en un intento de reemplazar las excepciones. En su lugar, se deben aplicar con inteligencia para abordar aspectos específicos de la directiva de administración de excepciones y errores de maneras dirigidas.

Aplicación parcial y programación sin puntos

F# admite aplicaciones parciales y, por tanto, varias maneras de programar en un estilo sin puntos. Esto puede ser beneficioso para la reutilización de código dentro de un módulo o la implementación de algo, pero no es algo que exponer públicamente. En general, la programación sin puntos no es una virtud en sí misma y puede agregar una barrera cognitiva significativa para las personas que no están inmersas en el estilo.

No usar aplicaciones parciales ni la aplicación en api públicas

Con poca excepción, el uso de una aplicación parcial en las API públicas puede resultar confuso para los consumidores. Normalmente, let los valores enlazados a en el código de F# son valores, no valores de función. La combinación de valores y valores de función puede provocar que se guarden algunas líneas de código a cambio de bastante sobrecarga cognitiva, especialmente si se combinan con operadores como para crear >> funciones.

Tenga en cuenta las implicaciones de las herramientas para la programación sin puntos

Las funciones consultadas no etiquetan sus argumentos. Esto tiene implicaciones en las herramientas. Tenga en cuenta las dos funciones siguientes:

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 son funciones válidas, funcWithApplication pero es una función consultada. Al mantener el puntero sobre sus tipos en un editor, verá lo siguiente:

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

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

En el sitio de llamada, la información sobre herramientas de herramientas como Visual Studio le dará la firma de tipo, pero como no hay nombres definidos, no mostrará nombres. Los nombres son fundamentales para un buen diseño de API porque ayudan a los llamadores a comprender mejor el significado que hay detrás de la API. El uso de código sin puntos en la API pública puede dificultar la información de los llamadores.

Si encuentra código sin puntos como este que se puede consumir públicamente, se recomienda realizar una expansión de η completa para que las herramientas puedan elegir nombres significativos para los funcWithApplication argumentos.

Además, la depuración de código sin puntos puede ser difícil, si no imposible. Las herramientas de depuración se basan en valores enlazados a nombres (por ejemplo, enlaces) para que pueda inspeccionar los valores intermedios a mitad let de la ejecución. Cuando el código no tiene valores que inspeccionar, no hay nada que depurar. En el futuro, las herramientas de depuración pueden evolucionar para sintetizar estos valores en función de las rutas de acceso ejecutadas previamente, pero no es buena idea basarse en las posibles funcionalidades de depuración.

Considere la aplicación parcial como una técnica para reducir la reutilizable interna.

A diferencia del punto anterior, la aplicación parcial es una herramienta magnífica para reducir la reutilizable dentro de una aplicación o los aspectos internos más profundos de una API. Puede ser útil para realizar pruebas unitarias de la implementación de API más complicadas, donde la reutilizable suele ser difícil de tratar. Por ejemplo, el código siguiente muestra cómo puede lograr lo que la mayoría de los marcos ficticios le dan sin tener que tomar una dependencia externa de este tipo de marco y tener que aprender una API personalizada relacionada.

Por ejemplo, considere la siguiente topografía de solución:

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

ImplementationLogic.fsproj podría exponer código como:

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

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

Las Transactions.doTransaction pruebas unitarias ImplementationLogic.Tests.fsproj en son fáciles:

namespace TransactionsTestingUtil

open Transactions

module TransactionsTestable =
    let getTestableTransactionRoutine mockContext = Transactions.doTransaction mockContext

La aplicación parcial con un objeto de contexto ficticio permite llamar a la función en todas las pruebas unitarias sin necesidad de construir un contexto ficticio doTransaction 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)

No aplique esta técnica universalmente a todo el código base, pero es una buena manera de reducir la reutilizable para los internos complicados y las pruebas unitarias de esos internos.

Control de acceso

F# tiene varias opciones para el control de acceso,heredadas de lo que está disponible en el entorno de ejecución de .NET. No solo se pueden usar para los tipos: también se pueden usar para las funciones.

  • Prefiere tipos y public miembros que no sean hasta que necesite que se puedan consumir públicamente. Esto también minimiza a qué se unen los consumidores.
  • Esfuérzse por mantener todas las funciones auxiliares private .
  • Considere el uso de [<AutoOpen>] en un módulo privado de funciones auxiliares si son numerosas.

Inferencia de tipos y genéricos

La inferencia de tipos puede ahorrarle escribir una gran cantidad de reutilizable. Y la generalización automática en el compilador de F# puede ayudarle a escribir código más genérico sin casi ningún esfuerzo adicional por su parte. Sin embargo, estas características no son universalmente buenas.

  • Considere la posibilidad de etiquetar nombres de argumento con tipos explícitos en las API públicas y no se base en la inferencia de tipos para esto.

    El motivo es que debe tener el control de la forma de la API, no del compilador. Aunque el compilador puede hacer un buen trabajo al inferir tipos por usted, es posible que la forma de la API cambie si los elementos internos en los que se basa han cambiado los tipos. Esto puede ser lo que desea, pero casi con seguridad dará lugar a un cambio importante en la API con el que los consumidores de nivel inferior tendrán que tratar. En su lugar, si controla explícitamente la forma de la API pública, puede controlar estos cambios importantes. En términos de DDD, esto se puede pensar como una capa de protección contra daños.

  • Considere la posibilidad de dar un nombre descriptivo a los argumentos genéricos.

    A menos que escriba código realmente genérico que no sea específico de un dominio determinado, un nombre significativo puede ayudar a otros programadores a comprender el dominio en el que están trabajando. Por ejemplo, un parámetro de tipo denominado en el contexto de interactuar con una base de datos de documentos hace más claro que la función o miembro con el que está trabajando puede aceptar tipos de documentos 'Document genéricos.

  • Considere la posibilidad de asignar un nombre a los parámetros de tipo genérico con PascalCase.

    Esta es la manera general de hacer cosas en .NET, por lo que se recomienda usar PascalCase en lugar de snake_case o camelCase.

Por último, la generalización automática no siempre es una buena opción para las personas que no están nuevas en F# o en un código base grande. Hay una sobrecarga cognitiva en el uso de componentes que son genéricos. Además, si las funciones generalizadas automáticamente no se usan con tipos de entrada diferentes (y mucho menos si están diseñadas para usarse como tales), entonces no hay ninguna ventaja real de que sean genéricas. Tenga en cuenta siempre si el código que está escribiendo realmente se beneficiará de ser genérico.

Rendimiento

Considere la posibilidad de usar estructuras para tipos pequeños con altas tasas de asignación.

El uso de structs (también denominados tipos de valor) a menudo puede dar lugar a un mayor rendimiento para algún código, ya que normalmente evita asignar objetos. Sin embargo, los structs no siempre son un botón "ir más rápido": si el tamaño de los datos de una estructura supera los 16 bytes, copiar los datos a menudo puede dar lugar a más tiempo de CPU que usar un tipo de referencia.

Para determinar si debe usar un struct, tenga en cuenta las condiciones siguientes:

  • Si el tamaño de los datos es de 16 bytes o más pequeño.
  • Si es probable que tenga muchas instancias de estos tipos que residen en la memoria en un programa en ejecución.

Si se aplica la primera condición, por lo general debe usar una estructura . Si se aplican ambos, casi siempre debe usar un struct. Puede haber algunos casos en los que se apliquen las condiciones anteriores, pero el uso de una estructura no es mejor o peor que usar un tipo de referencia, pero es probable que sean poco frecuentes. Sin embargo, es importante medir siempre al realizar cambios como este, y no operar en función de suposiciones o indesaciones.

Considere la posibilidad de usar tuplas de estructura al agrupar tipos de valor pequeños con altas tasas de asignación

Tenga en cuenta las dos funciones siguientes:

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)

Al realizar pruebas comparativas de estas funciones con una herramienta de pruebas comparativas estadísticas como BenchmarkDotNet,verá que la función que usa tuplas de estructura se ejecuta un 40 % más rápido y no asigna runWithStructTuple memoria.

Sin embargo, estos resultados no siempre serán el caso en su propio código. Si marca una función como , el código que usa tuplas de referencia puede obtener algunas optimizaciones adicionales, o el código que se asignaría podría simplemente inline optimizarse. Siempre debe medir los resultados siempre que se trate del rendimiento y nunca operar en función de la suposición o la intuición.

Considere la posibilidad de usar registros de estructura cuando el tipo es pequeño y tiene altas tasas de asignación.

La regla general descrita anteriormente también contiene para los tipos de registro de F#. Tenga en cuenta los siguientes tipos de datos y funciones que los procesan:

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)

Esto es similar al código de tupla anterior, pero esta vez en el ejemplo se usan registros y una función interna inlined.

Al realizar pruebas comparativas de estas funciones con una herramienta de pruebas comparativas estadísticas como BenchmarkDotNet,verá que se ejecuta casi un 60 % más rápido y no asigna nada en el processStructPoint montón administrado.

Considere la posibilidad de estructurar uniones discriminadas cuando el tipo de datos es pequeño con altas tasas de asignación

Las observaciones anteriores sobre el rendimiento con tuplas de estructura y registros también se mantienen para las uniones discriminadas de F#. Observe el código siguiente:

    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

Es habitual definir uniones discriminadas de un solo caso como esta para el modelado de dominios. Al realizar pruebas comparativas de estas funciones con una herramienta de pruebas comparativas estadísticas como BenchmarkDotNet,verá que se ejecuta aproximadamente un 25 % más rápido que para structReverseName reverseName cadenas pequeñas. En el caso de las cadenas de gran tamaño, ambas realizan aproximadamente lo mismo. Por lo tanto, en este caso, siempre es preferible usar una estructura . Como se mencionó anteriormente, mida siempre y no funcione según suposiciones o intuición.

Aunque en el ejemplo anterior se mostró que una estructura Unión discriminada produjo un mejor rendimiento, es habitual tener uniones discriminadas mayores al modelar un dominio. Es posible que tipos de datos de mayor tamaño como este no se ejecuten tan bien si son estructuras en función de las operaciones que se realicen en ellos, ya que podría haber más operaciones de copia implicadas.

Programación funcional y mutación

Los valores de F# son inmutables de forma predeterminada, lo que permite evitar ciertas clases de errores (especialmente aquellos que implican simultaneidad y paralelismo). Sin embargo, en algunos casos, con el fin de lograr una eficacia óptima (o incluso razonable) del tiempo de ejecución o las asignaciones de memoria, es mejor implementar un intervalo de trabajo mediante la mutación en contexto del estado. Esto es posible de forma opt-in con F# con la palabra mutable clave .

El uso mutable de en F# puede estar en contra de la puridad funcional. Esto es comprensible, pero la puridad funcional en todas partes puede estar en contra de los objetivos de rendimiento. Un riesgo es encapsular la mutación de forma que los autores de la llamada no necesiten importar lo que sucede cuando llaman a una función. Esto le permite escribir una interfaz funcional en una implementación basada en mutaciones para código crítico para el rendimiento.

Encapsular código mutable en interfaces inmutables

Con la transparencia referencial como objetivo, es fundamental escribir código que no exponga la falta mutable de funciones críticas para el rendimiento. Por ejemplo, el código siguiente implementa la Array.contains función en la biblioteca principal de 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

Llamar a esta función varias veces no cambia la matriz subyacente ni requiere mantener ningún estado mutable al consumirla. Es referencialmente transparente, aunque casi todas las líneas de código de su interior usan la mutación.

Considere la posibilidad de encapsular datos mutables en clases

En el ejemplo anterior se usaba una sola función para encapsular las operaciones mediante datos mutables. Esto no siempre es suficiente para conjuntos de datos más complejos. Tenga en cuenta los siguientes conjuntos de funciones:

open System.Collections.Generic

let addToClosureTable (key, value) (t: Dictionary<_,_>) =
    if not (t.ContainsKey(key)) then
        t.Add(key, value)
    else
        t[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

Este código tiene un rendimiento, pero expone la estructura de datos basada en mutaciones que los autores de la llamada son responsables de mantener. Esto se puede encapsular dentro de una clase sin miembros subyacentes que puedan cambiar:

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 not (t.ContainsKey(key)) then
            t.Add(key, value)
        else
            t[key] <- value

    member _.Count = t.Count

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

Closure1Table encapsula la estructura de datos subyacente basada en la mutación, lo que no obliga a los autores de la llamada a mantener la estructura de datos subyacente. Las clases son una manera eficaz de encapsular datos y rutinas basados en mutaciones sin exponer los detalles a los autores de la llamada.

Prefiere let mutable hacer referencia a celdas

Las celdas de referencia son una manera de representar la referencia a un valor en lugar del propio valor. Aunque se pueden usar para el código crítico para el rendimiento, no se recomiendan. Considere el ejemplo siguiente:

let kernels =
    let acc = ref Set.empty

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

    !acc |> Seq.toList

El uso de una celda de referencia ahora "incueste" todo el código posterior con la necesidad de desreferenciar y volver a hacer referencia a los datos subyacentes. En su lugar, 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

Aparte del único punto de mutación en medio de la expresión lambda, el resto del código que toque puede hacerlo de una manera distinta al uso de un valor acc let inmutable normal enlazado. Esto hará que sea más fácil cambiar con el tiempo.

Programación de objetos

F# tiene compatibilidad completa con objetos y conceptos orientados a objetos (OO). Aunque muchos conceptos de OO son eficaces y útiles, no todos son ideales para su uso. En las listas siguientes se ofrecen instrucciones sobre las categorías de características de OO en un nivel alto.

Considere la posibilidad de usar estas características en muchas situaciones:

  • Notación de puntos ( x.Length )
  • Miembros de instancia
  • Constructores implícitos
  • Miembros estáticos
  • Indexador notation ( arr[x] ), mediante la definición de una Item propiedad
  • Delimitación de la notación ( arr[x..y] , , ), mediante la arr[x..] arr[..y] definición de GetSlice miembros
  • Argumentos opcionales y con nombre
  • Interfaces e implementaciones de interfaz

No llegue primero a estas características, pero aplíconsílas de forma judiciosa cuando sean convenientes para resolver un problema:

  • Sobrecarga de métodos
  • Datos mutables encapsulados
  • Operadores en tipos
  • Propiedades automáticas
  • Implementación de IDisposable y IEnumerable
  • Extensiones de tipo
  • Eventos
  • Estructuras
  • Delegados
  • Enumeraciones

Por lo general, evite estas características a menos que deba usarlas:

  • Jerarquías de tipos basadas en herencia y herencia de implementación
  • Valores NULL y Unchecked.defaultof<_>

Preferir la composición sobre la herencia

La composición sobre la herencia es una expresión de larga duración a la que se puede cumplir un buen código de F#. El principio fundamental es que no debe exponer una clase base y forzar a los llamadores a heredar de esa clase base para obtener la funcionalidad.

Usar expresiones de objeto para implementar interfaces si no necesita una clase

Las expresiones de objeto permiten implementar interfaces sobre la marcha, enlazando la interfaz implementada a un valor sin necesidad de hacerlo dentro de una clase. Esto es práctico, especialmente si solo necesita implementar la interfaz y no necesita una clase completa.

Por ejemplo, este es el código que se ejecuta en Ionide para proporcionar una acción de corrección de código si ha agregado un símbolo para el que no tiene una open instrucción:

    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
        }

Dado que no es necesario que una clase interactúe con la API Visual Studio Code, las expresiones de objeto son una herramienta ideal para esto. También son útiles para las pruebas unitarias, cuando se quiere crear un código auxiliar de una interfaz con rutinas de prueba de una manera improvisada.

Considere la posibilidad de usar abreviaturas de tipos para acortar las firmas

Las abreviaturas de tipo son una manera cómoda de asignar una etiqueta a otro tipo, como una firma de función o un tipo más complejo. Por ejemplo, el siguiente alias asigna una etiqueta a lo que se necesita para definir un cálculo con CNTK, una biblioteca de aprendizaje profundo:

open CNTK

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

El Computation nombre es una manera cómoda de denotar cualquier función que coincida con la firma a la que se está haciendo un alias. El uso de abreviaturas de tipo como este es práctico y permite código más concisa.

Evitar el uso de abreviaturas de tipo para representar el dominio

Aunque las abreviaturas de tipo son prácticas para dar un nombre a las firmas de función, pueden resultar confusas al abreviar otros tipos. Tenga en cuenta esta abreviatura:

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

Esto puede resultar confuso de varias maneras:

  • BufferSize no es una abstracción; es simplemente otro nombre para un entero.
  • Si se expone en una API pública, se puede malinterpretar fácilmente para BufferSize significar algo más que int . Por lo general, los tipos de dominio tienen varios atributos y no son tipos primitivos como int . Esta abreviatura infringe esa suposición.
  • El uso de BufferSize mayúsculas y minúsculas de (PascalCase) implica que este tipo contiene más datos.
  • Este alias no ofrece mayor claridad en comparación con proporcionar un argumento con nombre a una función.
  • La abreviatura no se manifestará en il compilado; es simplemente un entero y este alias es una construcción en tiempo de compilación.
module Networking =
    ...
    let send data (bufferSize: int) = ...

En resumen, el obstáculo con las abreviaturas de tipo es que no son abstracciones sobre los tipos que abrevian. En el ejemplo anterior, es simplemente un elemento en la parte inferior, sin datos adicionales, ni ninguna ventajas del sistema de tipos además de BufferSize int lo que ya int tiene.

Un enfoque alternativo al uso de abreviaturas de tipo para representar un dominio es usar uniones discriminadas de un solo caso. El ejemplo anterior se puede modelar de la siguiente manera:

type BufferSize = BufferSize of int

Si escribe código que funciona en términos de y su valor subyacente, debe construir uno en lugar de pasar BufferSize cualquier entero arbitrario:

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

Esto reduce la probabilidad de pasar erróneamente un entero arbitrario a la función, porque el autor de la llamada debe construir un tipo para encapsular un valor antes de llamar send BufferSize a la función.