Nota:
El acceso a esta página requiere autorización. Puede intentar iniciar sesión o cambiar directorios.
El acceso a esta página requiere autorización. Puede intentar cambiar los directorios.
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:
- Mover cualquier estado dependiente fuera de la propia API.
- La configuración ahora se puede realizar fuera de la API.
- Es improbable que los errores en la inicialización de valores dependientes se manifiesten como
TypeInitializationException. - 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:
- Es más fácil mantener a medida que cambia el dominio a lo largo del tiempo.
- 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
publichasta 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
'Documenten 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 propiedadItem - Notación de corte (
arr[x..y],arr[x..],arr[..y]), definiendo los miembrosGetSlice - 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
IDisposableyIEnumerable - 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:
-
BufferSizeno es una abstracción; es solo otro nombre para un entero. - Si
BufferSizese expone en una API pública, se puede malinterpretar fácilmente para significar algo más que simplementeint. Por lo general, los tipos de dominio tienen varios atributos para ellos y no son tipos primitivos comoint. 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.