Convenciones de código de F#

Las siguientes convenciones se formulan a partir de la experiencia que trabaja con grandes bases de código de F#. Los cinco principios del buen código de F# son la base de cada recomendación. Están relacionados con las instrucciones 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# presenta 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 .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 anidados dentro de otros módulos.
  • Los espacios de nombres pueden abarcar varios archivos. Los módulos no pueden hacerlo.
  • Los módulos se pueden decorar con [<RequireQualifiedAccess>] y [<AutoOpen>].

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

Mejor usar espacios de nombres en el nivel superior

Para cualquier código consumible públicamente, los espacios de nombres son preferenciales a los módulos en el nivel superior. Dado que se compilan como espacios de nombres de .NET, se pueden consumir desde C# sin recurrir a using static.

// Recommended.
namespace MyCode

type MyClass() =
    ...

Es posible que el uso de un módulo de nivel superior no aparezca diferente cuando se llama solo desde F#, pero para los consumidores de C#, los autores de llamadas pueden sorprenderse por tener que calificar MyClass con el módulo MyCode cuando no conozcan la construcción específica using static de C#.

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

type MyClass() =
    ...

Aplicar [<AutoOpen>] cuidadosamente

La construcción [<AutoOpen>] puede contaminar el ámbito de lo que está disponible para los llamadores, y la respuesta a dónde procede algo es «magia». Y eso no es algo bueno. Una excepción a esta regla es la propia biblioteca principal de F# (aunque este hecho también es un poco controvertido).

Sin embargo, es una comodidad 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 a un asistente cada vez que lo llame.

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

Use [<RequireQualifiedAccess>] cada vez que los nombres puedan entrar en conflicto o cree que ayuda con la legibilidad

Agregar el atributo [<RequireQualifiedAccess>] a un módulo indica que es posible que el módulo no se abra y que las referencias a los elementos del módulo requieran acceso explícito calificado. Por ejemplo, el módulo Microsoft.FSharp.Collections.List 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 cualificado puede aumentar considerablemente la capacidad de mantenimiento a largo plazo y la 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 instrucciones open topológicamente

En F#, el orden de las declaraciones es importante, incluidas las declaraciones open (y open type, simplemente se hace referencia como open más abajo). Esto es diferente de C#, donde el efecto de using y using static es independiente del orden de esas instrucciones en un archivo.

En F#, los elementos abiertos en un ámbito pueden reemplazar a otros ya presentes. Esto significa que las instrucciones de reordenación open 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), no se recomienda generar un comportamiento diferente que se pueda esperar.

En su lugar, se recomienda ordenarlos topológicamente; es decir, ordene las instrucciones open en el orden en que se definen las capas del sistema. También se puede considerar la realización de una 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 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 cada capa se ordena alfanuméricamente después. Esto organiza de forma limpia el código sin reemplazar accidentalmente los valores.

Usar clases para contener valores que tienen efectos secundarios

Hay muchas veces que la inicialización de un valor puede tener efectos secundarios, como crear instancias de un contexto a una base de datos u otro recurso remoto. Es tentador inicializar estos elementos en un módulo y usarlo en funciones 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

Esto suele ser problemático 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 códigos base 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 incumplido 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 a let en ese módulo, se manifiesta como un TypeInitializationException que, a continuación, se almacena en caché durante toda la duración de la aplicación. Esto puede ser difícil de depurar. Normalmente hay una excepción interna que puede intentar razonar, pero si no lo hay, no hay ninguna indicación de lo que 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

Esta opción 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. Es probable que los errores en la inicialización de valores dependientes se manifesten 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 soluciones milagrosas que garanticen que los sistemas sean tolerantes a errores y se comporten correctamente. Las siguientes instrucciones deben servir como guía para navegar por este difícil espacio.

Representar casos de error y estado no válido en tipos intrínsecos al dominio

Con uniones discriminadas, F# le ofrece la capacidad de representar el estado del 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 retirar dinero de una cuenta bancaria puede fallar. Cada caso de error está representado en el tipo y así puede ser tratado con seguridad a lo largo del 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 algo puede producir un error en el dominio, el código de control de errores ya no se trata como algo con el que debe tratar además del flujo de programa normal. Es simplemente una parte del flujo de programa normal y no se considera excepcional. Hay dos ventajas principales:

  1. Es más fácil mantener a medida que cambia el dominio a lo largo del tiempo.
  2. Con 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 de problema. Estos tipos de errores son excepcionales por naturaleza, por lo que la capacidad de generar y detectar excepciones en F#.

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

Las construcciones principales disponibles en F# para los fines de generar excepciones se deben tener en cuenta en el siguiente orden de preferencia:

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

Use nullArg, invalidArgy invalidOp como mecanismo para iniciar ArgumentNullException, ArgumentExceptiony InvalidOperationException cuando corresponda.

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

Uso de la sintaxis de control de excepciones

F# admite patrones de excepción mediante la sintaxis 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

La funcionalidad de conciliación para realizar frente a una excepción con coincidencia de patrones puede ser un poco complicado 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 en sí. Por ejemplo, puede consumir una API que, cuando produce una excepción, incluye información valiosa en los metadatos de la excepción. Desajustar un valor útil en el cuerpo de la excepción capturada dentro del patrón activo y devolver ese valor puede ser útil en algunas situaciones.

No use el control de errores monádicos para reemplazar las excepciones

Las excepciones a menudo se ven como tabú en el paradigma funcional puro. De hecho, las excepciones infringen la pureza, por lo que es seguro considerarlas no muy puras funcionalmente. Sin embargo, esto omite la realidad de dónde se debe ejecutar el código y que pueden producirse errores en tiempo de ejecución. En general, escriba código suponiendo que la mayoría de las cosas no son puras o totales, para minimizar sorpresas desagradables (similar a catch vacías en C# o que no coinciden con el seguimiento de la pila, descartando información).

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

  • Contienen información de diagnóstico detallada, que resulta útil al depurar una incidencia.
  • Son comprendidos correctamente por el runtime y otros lenguajes de .NET.
  • Pueden reducir considerablemente la reutilizable en comparación con el código que sale de su camino para evitar excepciones mediante la implementación de algún subconjunto de su semántica ad hoc.

Este tercer punto es crítico. En el caso de las operaciones complejas no triviales, si no se usan excepciones, se puede tratar con 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 «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 tragar cualquier excepción en el deseo de una función «simple» que devuelve un «mejor» tipo:

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

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

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

Y colocar el propio objeto de excepción en el constructor Error simplemente le obliga a tratar correctamente con el tipo de excepción en el sitio de llamada en lugar de en la función. Al hacerlo, se crean excepciones comprobadas, que no son conocidas como un autor de llamada 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 se modifica la función tryReadAllText como se indica a continuación, None tiene más significado:

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

En lugar de funcionar como 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 puede asignarse a ese caso de error, a la vez que no descarta ninguna información contextual ni obliga a los autores de llamadas a tratar con un caso que puede no ser relevante en ese punto del código.

Los tipos como Result<'Success, 'Error> 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 algo o nada. Sin embargo, no son un reemplazo de las excepciones y no se deben usar en un intento de reemplazar las excepciones. En su lugar, deben aplicarse con criterio para abordar aspectos específicos de la directiva de administración de excepciones y errores de maneras específicas.

Aplicación parcial y programación sin puntos

F# admite aplicaciones parciales y, por tanto, varias maneras de programar en un estilo sin punto. 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 la aplicación parcial ni el uso de currificación en las API públicas

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

Considerar las implicaciones de las herramientas para la programación sin puntos

Las funciones currificadas no etiquetan sus argumentos. Esto tiene implicaciones en cuanto a las herramientas. Considere las siguientes dos funciones:

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, pero funcWithApplication es una función currificada. 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 proporcionará la firma de tipo, pero dado que no hay nombres definidos, no mostrará nombres. Los nombres son fundamentales para un buen diseño de API, ya que ayudan a los autores de llamadas a comprender mejor el significado detrás de la API. El uso de código sin puntos en la API pública puede dificultar la comprensión de los autores de llamadas.

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

Además, la depuración de código sin punto puede ser difícil, si no imposible. Las herramientas de depuración se basan en valores enlazados a nombres (por ejemplo, enlaces let) para que pueda inspeccionar los valores intermedios a mitad 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 una buena idea cubrir las apuestas sobre la funcionalidad de depuración potencial.

Considerar la posibilidad de una aplicación parcial como técnica para reducir la reutilizable interna

A diferencia del punto anterior, la aplicación parcial es una herramienta maravillosa para reducir reutilizables dentro de una aplicación o los aspectos internos más profundos de una API. Puede ser útil para probar unitariamente la implementación de API más complicadas, donde reutilizable suele ser un problema para tratar. Por ejemplo, en el código siguiente se muestra cómo puede lograr lo que la mayoría de los marcos de trabajo ficticios proporcionan sin tener que tomar una dependencia externa en este marco y tener que aprender una API personalizada relacionada.

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

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

ImplementationLogic.fsproj puede exponer código como:

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

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

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

namespace TransactionsTestingUtil

open Transactions

module TransactionsTestable =
    let getTestableTransactionRoutine mockContext = Transactions.doTransaction mockContext

La aplicación de doTransaction 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 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 las pruebas internas complicadas y las pruebas unitarias de esos elementos internos.

Control de acceso

F# tiene varias opciones para el Control de acceso, heredada de lo que está disponible en el runtime de .NET. Estos no solo son utilizables para los tipos: también puede usarlos para funciones.

Procedimientos recomendados en el contexto de las bibliotecas que se consumen ampliamente:

  • Prefiera que no sean tipos y miembros public hasta que necesite que sean consumibles públicamente. Esto también minimiza a lo que se acoplan los consumidores.
  • Se esfuerza por mantener toda la funcionalidad del asistente private.
  • Tenga en cuenta el uso de [<AutoOpen>] en un módulo privado de funciones auxiliares si se convierten en numerosos.

Inferencia de tipos y genéricos

La inferencia de tipos puede ahorrarle la escritura de una gran cantidad de reutilizables. Además, 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 usted debe estar en el control de la forma de la API, no del compilador. Aunque el compilador puede realizar un trabajo preciso en la inferencia de tipos, 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 quiera, pero casi seguramente dará lugar a un cambio importante en la API con el que los consumidores de bajada 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 considerar como una capa de protección contra daños.

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

    A menos que esté escribiendo 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 'Document en el contexto de interactuar con una base de datos de documentos hace más claro que la función o el miembro con el que trabaja pueden aceptar los tipos de documento genéricos.

  • Considere la posibilidad de asignar nombres a 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 boon para las personas que no están familiarizados con F# o un código base grande. Hay sobrecarga cognitiva en el uso de componentes que son genéricos. Además, si las funciones generalizadas automáticamente no se usan con diferentes tipos de entrada (por ejemplo, si están diseñadas para usarse como tal), entonces no hay ninguna ventaja real para que sean genéricas. Considere siempre si el código que está escribiendo se beneficiará realmente de ser genérico.

Rendimiento

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

El uso de estructuras (también denominadas 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, las estructuras 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, la copia de los datos a menudo puede dar lugar a más tiempo de CPU que el uso de un tipo de referencia.

Para determinar si debe usar una estructura, tenga en cuenta las condiciones siguientes:

  • Si el tamaño de los datos es de 16 bytes o menor.
  • Si es probable que tenga muchas instancias de estos tipos residentes en memoria en un programa en ejecución.

Si se aplica la primera condición, generalmente debe usar una estructura. Si ambos se aplican, casi siempre debe usar una estructura. Puede haber algunos casos en los que se aplican 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. Es importante medir siempre al realizar cambios como este, sin embargo, y no operar en la suposición o intuición.

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

Considere las siguientes dos funciones:

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 runWithStructTuple que usa tuplas de estructura se ejecuta un 40 % más rápido y no asigna memoria.

Sin embargo, estos resultados no siempre serán el caso en su propio código. Si marca una función como inline, el código que usa tuplas de referencia puede obtener algunas optimizaciones adicionales o el código que se asignaría podría simplemente 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 los registros de estructura cuando el tipo es pequeño y tiene tasas de asignación altas

La regla general descrita anteriormente también contiene 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 el ejemplo usa registros y una función interna insertada.

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

Considerar las uniones discriminadas de estructura 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 incluyen 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 mayúsculas y minúsculas como esta para el modelado de dominios. Al realizar pruebas comparativas de estas funciones con una herramienta de pruebas comparativas estadísticas como BenchmarkDotNet, encontrará que structReverseName se ejecuta aproximadamente un 25 % más rápido que reverseName para cadenas pequeñas. En el caso de las cadenas grandes, ambas realizan casi lo mismo. Por lo tanto, en este caso, siempre es preferible usar una estructura. Como se mencionó anteriormente, mida siempre y no opere en suposiciones o intuición.

Aunque en el ejemplo anterior se mostró que una unión discriminada de estructura produjo un mejor rendimiento, es habitual tener uniones discriminadas mayores al modelar un dominio. Es posible que los tipos de datos más grandes como ese no funcionen bien si son estructuras en función de las operaciones en ellos, ya que podría haber más copias.

Inmutabilidad y mutación

Los valores de F# son inmutables de forma predeterminada, lo que permite evitar ciertas clases de errores (especialmente aquellas que implican simultaneidad y paralelismo). Sin embargo, en ciertos casos, para lograr una eficiencia óptima (o incluso razonable) del tiempo de ejecución o las asignaciones de memoria, se puede implementar mejor un intervalo de trabajo mediante la mutación local del estado. Esto es posible en una base de participación con F# con la palabra clave mutable.

El uso de mutable en F# puede sentirse en desacuerdo con la pureza funcional. Esto es comprensible, pero la pureza funcional en todas partes puede estar en desacuerdo con los objetivos de rendimiento. Un compromiso consiste en encapsular la mutación, de modo que los autores de llamadas no necesitan preocuparse por lo que sucede cuando llaman a una función. Esto le permite escribir una interfaz funcional en una implementación basada en mutaciones para el código crítico para el rendimiento.

Además, las construcciones de enlace de F# let permiten anidar enlaces en otra, lo que se puede aprovechar para mantener el ámbito de la variable mutable cerca o en su tamaño más pequeño.

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

Ningún código puede tener acceso a la mutable completed que se usó solo para inicializar el valor enlazado let data.

Ajuste del código mutable en interfaces inmutables

Con la transparencia referencial como objetivo, es fundamental escribir código que no exponga la subetiqueta mutable de funciones críticas para el rendimiento. Por ejemplo, el código siguiente implementa la función Array.contains 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 que mantenga ningún estado mutable en su consumo. Es referencialmente transparente, aunque casi todas las líneas de código que contiene usan la mutación.

Considerar la posibilidad de encapsular datos mutables en clases

En el ejemplo anterior se usó 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 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

Este código es eficaz, pero expone la estructura de datos basada en la mutación que los llamadores son responsables de mantener. Esto se puede ajustar 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 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 la estructura de datos basada en la mutación subyacente, por lo que no obliga a los autores de llamada a mantener la estructura de datos subyacente. Las clases son una manera eficaz de encapsular datos y rutinas que se basan en la mutación sin exponer los detalles a los autores de llamadas.

Preferir let mutablea ref

Las celdas de referencia son una manera de representar la referencia a un valor en lugar del propio valor. Aunque se pueden usar para código crítico para el rendimiento, no se recomienda. 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 «contamina» todo el código posterior con tener que 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

Además del único punto de mutación en medio de la expresión lambda, el resto de código que toca acc puede hacerlo de una manera que no es diferente al uso de un valor inmutable let normal enlazado. Esto facilitará el cambio a lo largo del tiempo.

Valores NULL y valores predeterminados

Por lo general, los valores NULL deben evitarse en F#. De forma predeterminada, los tipos declarados por F#no admiten el uso del null literal y se inicializan todos los valores y objetos. Sin embargo, algunas API de .NET comunes devuelven o aceptan valores NULL, y algunas comunes. Los tipos declarados por NET, como matrices y cadenas, permiten valores NULL. Sin embargo, la aparición de null valores es muy poco frecuente en la programación de F# y una de las ventajas de usar F# es evitar errores de referencia null en la mayoría de los casos.

Evitar el uso del AllowNullLiteral atributo

De forma predeterminada, los tipos declarados por F#no admiten el uso del null literal. Puede anotar manualmente los tipos de F# con AllowNullLiteral para permitir esto. Sin embargo, casi siempre es mejor evitar hacerlo.

Evitar el uso del Unchecked.defaultof<_> atributo

Es posible generar un null valor inicializado o cero para un tipo de F# mediante Unchecked.defaultof<_>. Esto puede ser útil al inicializar el almacenamiento para algunas estructuras de datos, o en algún patrón de codificación de alto rendimiento o en interoperabilidad. Sin embargo, se debe evitar el uso de esta construcción.

Evitar el uso del atributo DefaultValue

De forma predeterminada, los registros y objetos de F# deben inicializarse correctamente en la construcción. El atributo DefaultValue se puede usar para rellenar algunos campos de objetos con un valor null inicializado o cero. Esta construcción rara vez es necesaria y se debe evitar su uso.

Si comprueba si hay entradas nulas, genere excepciones en la primera oportunidad

Al escribir código de F#, en la práctica no es necesario comprobar si hay entradas nulas, a menos que espere que se use código desde C# u otros lenguajes .NET.

Si decide agregar comprobaciones de entradas nulas, realice las comprobaciones en la primera oportunidad y genere una excepción. Por ejemplo:

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 heredados, algunas funciones de cadena en FSharp.Core siguen tratando los valores NULL como cadenas vacías y no generan errores en argumentos NULL. Sin embargo, no tome esto como guía y no adopte patrones de codificación que atribuyan ningún significado semántico a "null".

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 ellos 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 explícitos
  • Miembros estáticos
  • Notación del indizador (arr[x]), mediante la definición de una propiedad Item
  • Segmentación de notación (arr[x..y], arr[x..], arr[..y]), mediante la definición de miembros GetSlice
  • Argumentos opcionales y con nombre
  • Interfaces e implementaciones de interfaces

No llegue primero a estas características, pero aplíquelas con criterio cuando sean convenientes para resolver un problema:

  • Sobrecarga de métodos
  • Datos mutables encapsulados
  • Operadores en tipos
  • Propiedades automáticas
  • implementar IDisposable y IEnumerable
  • Extensiones de tipo
  • Events
  • 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 puede adherirse el buen código de F#. El principio fundamental es que no debe exponer una clase base y forzar a los autores de llamadas a heredar de esa clase base para obtener la funcionalidad.

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

Las xpresiones 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 conveniente, 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 instrucción open para:

    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 de Visual Studio Code, las expresiones de objeto son una herramienta ideal para esto. También son valiosos para las pruebas unitarias, cuando se desea realizar el código auxiliar de una interfaz con rutinas de prueba de manera improvisada.

Considere la posibilidad de usar abreviaturas de tipo 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 alias siguiente 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 nombre Computation es una manera cómoda de indicar cualquier función que coincida con la firma a la que está poniendo alias. Usar abreviaturas de tipo como esta es conveniente y permite un código más conciso.

Evitar usar abreviaturas de tipo para representar el dominio

Aunque las abreviaturas de tipo son convenientes para asignar 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 se puede hacer de varias maneras:

  • BufferSize no es una abstracción; es solo otro nombre para un entero.
  • Si BufferSize se expone en una API pública, se puede malinterpretar fácilmente para significar algo más que simplemente int. Por lo general, los tipos de dominio tienen varios atributos para ellos y no son tipos primitivos como int. Esta abreviatura infringe esa suposición.
  • El uso de mayúsculas y minúsculas de BufferSize (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 solo un entero y este alias es una construcción en tiempo de compilación.
module Networking =
    ...
    let send data (bufferSize: int) = ...

En resumen, el problema con las abreviaturas de tipo es que no son abstracciones sobre los tipos que están abreviando. En el ejemplo anterior, BufferSize es simplemente un int en segundo plano, sin datos adicionales, ni ninguna ventaja del sistema de tipos además de lo que int ya tiene.

Un enfoque alternativo para usar 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 BufferSize y su valor subyacente, debe construir uno en lugar de pasar un entero arbitrario:

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

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