Compartir a través de


Convenciones de código de F#

Las siguientes convenciones se formulan a partir de la experiencia de trabajar con grandes bases de código 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.

Prefiera espacios de nombres en el nivel superior

Para cualquier código fuente consumible públicamente, los espacios de nombres son preferibles a los módulos a 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 parezca diferente cuando solo se llama desde F#, pero para los consumidores de C#, los llamadores pueden sorprenderse por tener que calificar MyClass con el módulo MyCode cuando no estén al tanto de 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 invocadores, y la respuesta sobre el origen de 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, resulta conveniente si tiene funcionalidad de apoyo 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 especificar completamente una función auxiliar cada vez que la 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 sentencias open (y open type, referidas como open más adelante). 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 ocultar 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), ya que podría generar un comportamiento diferente al esperado.

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 posteriormente. Esto organiza de forma limpia el código sin ocultar accidentalmente los valores.

Uso de clases para contener valores con efectos secundarios

A menudo, la inicialización de un valor puede tener efectos secundarios, como instanciar un contexto en una base de datos o en 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>/config-options.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 se viola claramente 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 diagnosticar. Normalmente hay una excepción interna que puede intentar comprender, pero si no la hay, entonces no se puede determinar 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

Esta opción permite lo siguiente:

  1. Mover cualquier estado dependiente fuera de la propia API.
  2. La configuración ahora se puede realizar fuera de la API.
  3. Es improbable que los errores en la inicialización de valores dependientes se manifiesten 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 inválido en tipos intrínsecos a tu dominio

Con uniones discriminadas, F# le ofrece la capacidad de representar el estado defectuoso del programa en su 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 en que 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. Los casos de error son más fáciles de probar con 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 es importante poder manejar 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 con el mensaje especificado.
raise raise (ExceptionType("message")) Mecanismo de uso general para lanzar excepciones.
failwith failwith "message" Lanza 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

Reconciliar la funcionalidad para ejecutarse ante una excepción con el uso de emparejamiento de patrones puede ser un poco complicada si se 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. Desempaquetar un valor útil en el cuerpo de la excepción capturada dentro del Patrón Activo y devolver ese valor puede ser beneficioso 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 partiendo de que la mayoría de las cosas no son completamente puras o totales, para minimizar sorpresas desagradables (similar a un catch vacío en C# o a un manejo incorrecto del seguimiento de la pila, lo que resulta en descartar 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 el código redundante en comparación con el código que se esfuerza por evitar excepciones al implementar algún subconjunto de su semántica de manera 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 el emparejamiento de patrones en errores "tipo 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 ignorar cualquier excepción con el deseo de tener una función "simple" que devuelva un tipo "más adecuado".

// 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. Hacer esto crea efectivamente excepciones comprobadas, que son notoriamente difíciles de manejar como llamador 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 una solución universal, esta función ahora gestionará adecuadamente el caso cuando no se encuentre un archivo y asignará ese resultado a un valor de retorno. 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 la 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 resultar en ahorrar algunas líneas de código a cambio de una sobrecarga cognitiva notable, especialmente si se combina con operadores como >> para componer funciones.

Considera 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 en entornos como Visual Studio mostrará la signatura del 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 point-free en la API pública puede dificultar la comprensión de los llamadores.

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 puntos puede ser un desafío, 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 aconsejable confiar en la funcionalidad de depuración potencial.

Considera la aplicación parcial como técnica para reducir el código repetitivo interno

A diferencia del punto anterior, la aplicación parcial es una herramienta maravillosa para reducir el código repetitivo 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 el código repetitivo a menudo es un problema a tratar. Por ejemplo, en el código siguiente se muestra cómo puede conseguir lo que la mayoría de los frameworks de simulación ofrecen, sin tener una dependencia externa de tal framework ni 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, aunque es una buena manera de reducir el código repetitivo para los internos complicados y realizar pruebas unitarias de esos 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 utilizar tipos y miembros no public hasta que necesite que sean accesibles 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 vuelven numerosos.

Inferencia de tipos y genéricos

La inferencia de tipos puede ahorrarle la escritura de una gran cantidad de código repetitivo. 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 quieras, pero casi seguramente dará lugar a un cambio importante en la API que los consumidores posteriores tendrán que afrontar. Si en cambio controla explícitamente la forma de su API pública, entonces puede gestionar estos cambios disruptivos. En términos de DDD, esto se puede considerar como una capa anticorrupción.

  • 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 ventaja para las personas que son nuevas en F# o con un gran código base. 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 structura. Si se aplican ambos, 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 usar tuplas estructurales 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 los mismos 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 usar registros de estructuras cuando el tipo es pequeño y tiene tasas de asignación altas

La regla general descrita anteriormente también se aplica a 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.

Considera las uniones discriminadas de estructura cuando el tipo de dato es pequeño y presenta altas tasas de asignaciones.

También se aplica a las uniones discriminadas de F# las observaciones anteriores sobre el rendimiento con tuplas de estructura y registros. 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 la modelización 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 ofrecen un rendimiento similar. 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 de forma opcional 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. Una solución intermedia consiste en encapsular la mutación, de manera que quienes llaman a la función no necesitan preocuparse por lo que sucede cuando la llaman. 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 dentro de otra, lo que se puede aprovechar para mantener el ámbito de la variable mutable cerca o en su teórico mínimo.

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

Ningún código puede acceder al elemento mutable completed que se usó solo para inicializar el valor vinculado por let data.

Envuelva el código mutable en interfaces inmutables

Con la transparencia referencial como objetivo, es fundamental escribir código que no exponga la parte mutable oculta de funciones críticas de 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 envolver 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 fuerza a los llamantes 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 mutable a 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 el centro de la expresión lambda, el resto del código que interactúa con acc puede hacerlo de manera similar al uso de un valor inmutable normal enlazado con let. 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 nulos, y algunos tipos comunes declarados por .NET, como matrices y cadenas, permiten nulos. 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 en F# no admiten el uso del literal null. 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 valor inicializado en 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 DefaultValue atributo

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 verifica entradas nulas, genere excepciones a 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".

Aprovechamiento de la sintaxis nula de F# 9 en los límites de la API

F# 9 agrega sintaxis para indicar explícitamente que un valor puede ser NULL. Está diseñado para usarse en los límites de la API, para que el compilador indique los lugares donde falta el control nulo.

Este es un ejemplo del uso válido de la sintaxis:

type CustomType(m1, m2) =
    member _.M1 = m1
    member _.M2 = m2

    override this.Equals(obj: obj | null) =
        match obj with
        | :? CustomType as other -> this.M1 = other.M1 && this.M2 = other.M2
        | _ -> false

    override this.GetHashCode() =
        hash (this.M1, this.M2)

Evita la propagación de valores NULL en diferentes partes de tu código F#.

let getLineFromStream (stream: System.IO.StreamReader) : string | null =
    stream.ReadLine()

En su lugar, use medios de F# idiomáticos (por ejemplo, opciones):

let getLineFromStream (stream: System.IO.StreamReader) =
    stream.ReadLine() |> Option.ofObj

Para generar excepciones relacionadas con NULL, puede usar funciones especiales de nullArgCheck y nonNull. También son útiles porque, en caso de que el valor no sea null, sombrea el argumento con su valor saneado: el resto del código ya no puede tener acceso a punteros null posibles.

let inline processNullableList list =
   let list = nullArgCheck (nameof list) list   // throws `ArgumentNullException`
   // 'list' is safe to use from now on
   list |> List.distinct

let inline processNullableList' list =
    let list = nonNull list                     // throws `NullReferenceException`
    // 'list' is safe to use from now on
    list |> List.distinct

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 implícitos
  • Miembros estáticos
  • Notación del indizador (arr[x]), mediante la definición de una propiedad Item
  • Notación de corte (arr[x..y], arr[x..], arr[..y]), definiendo los miembros GetSlice
  • Argumentos con nombre y opcionales
  • 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
  • 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 puede adherirse el 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 conseguir 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 simular una interfaz con rutinas de prueba de forma 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 puede ser confuso 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 estilo de nomenclatura 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 de los tipos que abrevian. 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.