3. Tuplas, records, uniones y opciones
Secciones
Ajustes de lectura
Ninguna voz para este idioma en este navegador.
Más voces
Código y términos técnicos en inglés
Desactivado: la voz de la página lo lee todo, sin pausa entre dos voces.
Ninguna voz para este idioma en este navegador.
Más voces
Más voces: Microsoft Edge ofrece voces naturales en línea, y Windows (Configuración › Hora e idioma › Voz), macOS y Android instalan otras.
Código: los scripts examples/l03_*.fsx y los fragmentos rechazados compile_fail/l03_*.fsx.
En C# y Java, los datos suelen vivir en clases: campos, un constructor, Equals y GetHashCode si te acuerdas, y una jerarquía de clases cuando un valor puede tomar varias formas. Los records (C# 9, Java 16) y las jerarquías selladas (C# con clases base abstract, Java 17 con sealed interface) lo han acortado. F# tiene formas cortas para todo ello desde el principio, y son la manera normal de modelar datos:
| Quieres | C# | Java | F# |
|---|---|---|---|
| unos cuantos valores juntos, sin nombre | (string, int) |
un record o Map.Entry |
tupla string * int |
| campos con nombre, inmutables, comparados por valor | record |
record |
record |
| una forma entre varias, cada una con sus datos | clase base abstracta y subclases | sealed interface y records |
unión discriminada |
| un valor que puede faltar | null, Nullable<T> |
null, Optional<T> |
Option<'T> |
// Una tupla agrupa valores sin declarar un tipo: aquí string * intlet lowString = ("E", 2)printfn "%A" lowStringprintfn "%s" (fst lowString)
// Deconstrucción, como var (note, octave) = ... en C#let (note, octave) = lowStringprintfn "%s%d" note octave
// Una función devuelve varios valores en una tuplalet octavesAndSemitones interval = (interval / 12, interval % 12)let octaves, semitones = octavesAndSemitones 29 // los paréntesis son opcionalesprintfn "29 semitones = %d octaves and %d semitones" octaves semitones
// Las tuplas F# son objetos System.Tuple; las tuplas struct son System.ValueTuple, las tuplas de C#let structTuple = struct ("A", 4)printfn "%s and %s" (lowString.GetType().Name) (structTuple.GetType().Name)("E", 2)EE229 semitones = 2 octaves and 5 semitonesTuple`2 and ValueTuple`2- El tipo de
("E", 2)se escribestring * int: el*se lee «y», como en un producto de tipos. fstysnddevuelven el primer y el segundo elemento de un par.- La última línea muestra una diferencia con C#: una tupla F# es un
System.Tuple, un objeto en el montón, mientras que el(string, int)de C# es unSystem.ValueTuple, una struct.struct ("A", 4)crea una tupla de valor, la que hay que usar al llamar a código C# que la espera. La página de tuplas tiene los detalles.
Una tupla sirve para dos o tres valores que viajan juntos un momento. En cuanto los valores tienen nombre en tu cabeza, dales nombre en el código: un record.
Records
Sección titulada «Records»type GuitarString = { Note: string; Octave: int; Gauge: float }
let low = { Note = "E"; Octave = 2; Gauge = 0.046 } // las etiquetas le indican el tipo a F#let high = { low with Octave = 4; Gauge = 0.010 } // copia y actualización, como with en C#
printfn "%s%d, gauge %.3f" low.Note low.Octave low.Gaugeprintfn "%A" high
// Igualdad y comparación estructurales, generadas por el compiladorprintfn "%b" (high = { Note = "E"; Octave = 4; Gauge = 0.010 })printfn "%b" (low = high)printfn "%b" (low < high) // campos comparados en orden: Note y luego OctaveE2, gauge 0.046{ Note = "E" Octave = 4 Gauge = 0.01 }truefalsetruetype GuitarString = { … }declara un record. El equivalente en C# esrecord GuitarString(string Note, int Octave, double Gauge);.- El valor
{ Note = "E"; Octave = 2; Gauge = 0.046 }no contiene ningún nombre de tipo: F# encuentra el tipo de record a partir de sus etiquetas. { low with Octave = 4; Gauge = 0.010 }es una copia con dos campos cambiados, ellow with { Octave = 4, Gauge = 0.010 }de C#.=compara los campos, y<los compara en orden, primeroNote. Los records de C# tienen igualdad por valor pero no orden; F# genera ambos, además de un código hash.
Hay que dar todos los campos, y ningún campo se puede cambiar:
type GuitarString = { Note: string; Octave: int; Gauge: float }
let low = { Note = "E"; Octave = 2 }l03_record_missing_field.fsx(3,11): error FS0764: No assignment given for field 'Gauge' of type 'FSI_0001.GuitarString'let low = { Note = "E"; Octave = 2; Gauge = 0.046 }low.Note <- "D"l03_record_immutable.fsx(4,1): error FS0005: This field is not mutableFSI_0001 es el módulo en el que F# Interactive envuelve el script. Un record de C# con parámetros posicionales se comporta igual, pero un record de C# también puede tener propiedades set, y un new de C# puede dejar una propiedad con su valor por defecto. Un record F# siempre tiene todos sus valores, desde el principio.
Uniones discriminadas
Sección titulada «Uniones discriminadas»Un acorde de do mayor en la guitarra se toca x32010: la cuerda de mi grave está apagada, luego traste 3, traste 2, una cuerda al aire, traste 1, una cuerda al aire. Cada cuerda está en una situación entre varias, y cada situación tiene datos distintos. En C#, escribirías una pequeña jerarquía de clases:
abstract record Fingering;sealed record Open : Fingering;sealed record Fretted(int Fret) : Fingering;sealed record Barre(int Fret, int Strings) : Fingering;sealed record Muted : Fingering;En F#, es un solo tipo, una unión discriminada:
// Una unión discriminada: un valor es exactamente uno de los casostype Accidental = | Natural | Sharp | Flat
// Cada caso puede llevar sus propios datostype Fingering = | Open | Fretted of fret: int | Barre of fret: int * strings: int | Muted
// Do mayor, de la cuerda de mi grave a la de mi agudo: x32010let cMajor = [ Muted; Fretted 3; Fretted 2; Open; Fretted 1; Open ]printfn "%A" cMajor
let fMajor = Barre(fret = 1, strings = 6) // campos con nombreprintfn "%A" fMajorprintfn "%b %b" (Fretted 3 = Fretted 3) (Sharp = Flat)[Muted; Fretted 3; Fretted 2; Open; Fretted 1; Open]Barre (1, 6)true falseAccidentaltiene casos sin datos: es una enumeración, pero cerrada. Unenumde C# acepta(Accidental)42; un valor de unión solo puede ser uno de sus casos.Fretted of fret: intlleva unint, llamadofret.Barre of fret: int * strings: intlleva dos valores. Los nombres son opcionales, y útiles: aparecen en la información sobre herramientas y permitenBarre(fret = 1, strings = 6).- Cada caso es también una función constructora:
Fretted 3construye un valor de tipoFingering, yFrettedsolo es una funciónint -> Fingering. - La igualdad y la comparación se generan, como en los records.
La verdadera ventaja aparece con la coincidencia de patrones, en la lección 4: el compilador conoce la lista completa de casos y avisa cuando a un match se le olvida uno. Un switch de C# sobre la jerarquía de records no puede saber que nadie añadió una quinta subclase en otro archivo.
Código real: el AST de acordes de GA
Sección titulada «Código real: el AST de acordes de GA»Guitar Alchemist analiza símbolos de acordes como F#m7b5/C y los convierte en un árbol sintáctico. Los tipos de ese árbol están en ChordAst.fs, y son records y uniones:
namespace GA.Business.DSL.Types
type QualityType = | Major | Minor | Diminished | Augmented | Suspended | Dominant // Derived during semantic analysis usually
type AccidentalType = | Natural | Sharp | Flat | DoubleSharp | DoubleFlat
type ChordComponent = | Extension of string | Alteration of AccidentalType * string | Omission of string | Alt
type ChordAst = { Root: string RootAccidental: AccidentalType Quality: QualityType option Components: ChordComponent list Bass: (string * AccidentalType) option }Veintisiete líneas describen todos los símbolos de acordes que entiende GA. Los tipos se anidan: un record ChordAst contiene una lista de uniones ChordComponent, uno de cuyos casos contiene un AccidentalType y una cadena. Quality: QualityType option dice que un acorde puede no tener calidad escrita (C7), y Bass: (string * AccidentalType) option que puede no tener bajo, y que un bajo es una tupla de una letra y una alteración. La versión en C# serían una docena de archivos.
El curso carga este archivo y el renderizador de GA, y construye F#m7b5/C a mano (examples/l03_ga_chords.fsx):
// El AST de acordes y el renderizador de Guitar Alchemist, cargados desde las copias de external/ga#load "../external/ga/ChordAst.fs" "../external/ga/ChordRenderer.fs"
open GA.Business.DSL.Typesopen GA.Business.DSL.Generators
// F#m7b5/C como valor del record ChordAst de GAlet halfDiminished = { Root = "F" RootAccidental = Sharp Quality = Some Minor Components = [ Extension "7"; Alteration(Flat, "5") ] Bass = Some("C", Natural) }
printfn "%s" (ChordRenderer.render halfDiminished)printfn "%A" halfDiminished
let c = { Root = "C"; RootAccidental = Natural; Quality = None; Components = []; Bass = None }printfn "%s" (ChordRenderer.render c)printfn "%s" (ChordRenderer.render { c with Quality = Some Major; Components = [ Extension "9" ] })F#m7b5/C{ Root = "F" RootAccidental = Sharp Quality = Some Minor Components = [Extension "7"; Alteration (Flat, "5")] Bass = Some ("C", Natural) }CCmaj9%A imprime el valor con sintaxis F#, que puedes volver a pegar en un script. ChordRenderer.render convierte el árbol de nuevo en texto; la lección 4 lee su código.
Option en lugar de null
Sección titulada «Option en lugar de null»Some Minor y None son los dos casos de Option, una unión definida en FSharp.Core:
type Option<'T> = | None | Some of 'Tint option es otra forma de escribir Option<int>. Un int option es Some 2 o None, y el tipo lo dice: el código que lo recibe debe tratar ambos. Un string de F#, en cambio, no debería ser null.
// Option: Some valor, o None, en lugar de nulllet capo: int option = Some 2let noCapo: int option = None
let describe capo = capo |> Option.map (fun fret -> $"capo on fret {fret}") |> Option.defaultValue "no capo"
printfn "%s, %s" (describe capo) (describe noCapo)printfn "%A %A" capo noCapo
// Un método .NET puede devolver null: Option.ofObj lo convierte en Nonelet variable = System.Environment.GetEnvironmentVariable "FSHARP_COURSE_VARIABLE_THAT_IS_NOT_SET"printfn "%A" (Option.ofObj variable)capo on fret 2, no capoSome 2 NoneNoneOption.mapaplica una función al valor que hay dentro deSomey dejaNonecomo está: elcapo?.ToString()de C#, pero para cualquier función.Option.defaultValuees el??de C#. El renderizador de GA usa esas mismas dos funciones para la calidad de un acorde:ast.Quality |> Option.map renderQuality |> Option.defaultValue "".Option.ofObjes el puente desde las API .NET, que devuelvennullpara «nada».
None no es un null disfrazado que se pueda olvidar comprobar: el tipo int option no es int, así que no puedes sumar capo + 1 sin decir qué pasa cuando no hay cejilla. Y null no es una opción:
let capo: int option = nulll03_option_null.fsx(1,24): error FS0043: The type 'int option' does not have 'null' as a proper valueCompara con el Optional<T> de Java, una clase cuya variable puede ser a su vez null, y con los tipos de referencia anulables de C#, que son advertencias añadidas sobre tipos que siguen aceptando null. F# 9 añadió tipos de referencia anulables para la interoperabilidad con las API .NET; la lección 9 los trata.
Uniones de un solo caso
Sección titulada «Uniones de un solo caso»Una unión con un solo caso envuelve un valor en un tipo propio. TARS da así tipos distintos a sus identificadores, en Primitives.fs:
/// Unique identifier for an agenttype AgentId = AgentId of Guid
/// Unique identifier for a correlation/conversationtype CorrelationId = CorrelationId of GuidLos dos contienen un Guid, pero no son intercambiables:
open System
type AgentId = AgentId of Guidtype CorrelationId = CorrelationId of Guid
let describeAgent (AgentId id) = $"agent {id}"
let conversation = CorrelationId(Guid.NewGuid())printfn "%s" (describeAgent conversation)l03_single_case_mixup.fsx(9,29): error FS0001: This expression was expected to have type 'AgentId'but here has type 'CorrelationId'Con parámetros Guid simples, el compilador habría aceptado la llamada. let describeAgent (AgentId id) = … desenvuelve el valor en el propio parámetro, un patrón que explica la lección 4 (examples/l03_single_case.fsx ejecuta la llamada correcta). En C#, lo más parecido es readonly record struct AgentId(Guid Value): más ceremonia, la misma seguridad.
Cuando los nombres de casos chocan
Sección titulada «Cuando los nombres de casos chocan»Los casos de unión son nombres en el ámbito, como las funciones. Si dos uniones tienen un caso con el mismo nombre, gana la definida en último lugar. Ok y Error son los casos del tipo Result de FSharp.Core (lección 7), siempre en el ámbito, así que una unión con su propio caso Error oculta Result.Error en todas las líneas que vienen después. Esta es la situación reducida a unas pocas líneas:
// Reducido a partir de TARS (v2/src/Tars.Core/Domain.fs): una unión con un caso llamado Errortype PartialFailure = | Warning of message: string | Error of message: string
let checkFret fret : Result<int, string> = if fret >= 0 then Ok fret else Error "negative fret"l03_case_shadowing.fsx(7,36): error FS0001: All branches of an 'if' expression must return values implicitly convertible to the type of the first branch, which here is 'Result<int,string>'. This branch returns a value of type 'PartialFailure'.Error "negative fret" construyó un PartialFailure, no un Result. TARS tiene exactamente esto. Domain.fs, el octavo archivo de Tars.Core, declara:
/// Represents a non-fatal issue encountered during executiontype PartialFailure = | Warning of message: string | Error of message: string | Degradation of feature: string * reason: string | Timeout of operation: string * duration: TimeSpan | SubAgentTimeout of agentId: AgentId * taskId: Guid | ToolError of tool: string * error: string | LowConfidence of score: float * details: string | ProtocolViolation of message: string | ConstraintViolation of violation: string
/// Represents the outcome of an agentic operation, supporting partial successtype ExecutionOutcome<'T> = | Success of value: 'T | PartialSuccess of value: 'T * warnings: PartialFailure list | Failure of errors: PartialFailure listEl efecto sigue el orden de archivos de la lección 1. Budget.fs, compilado antes que Domain.fs, escribe un simple Error e y obtiene Result.Error. Los archivos compilados después deben calificar: ConstitutionLoader.fs escribe Result.Error, y ArcTypes.fs llega a FSharp.Core.Result.Error. En este commit, v2/src contiene 342 apariciones de Result.Error. Failure choca del mismo modo: la unión Performative, unas líneas más arriba, también tiene un caso Failure, y el AgentWorkflow.fs de TARS escribe completos ExecutionOutcome.Success, ExecutionOutcome.PartialSuccess y ExecutionOutcome.Failure.
La respuesta de F# es el atributo RequireQualifiedAccess: los casos de la unión deben escribirse entonces con su nombre, así que no ocultan nada.
// RequireQualifiedAccess: los casos deben escribirse PartialFailure.Warning y PartialFailure.Error[<RequireQualifiedAccess>]type PartialFailure = | Warning of message: string | Error of message: string
let checkFret fret : Result<int, string> = if fret >= 0 then Ok fret else Error "negative fret" // Error vuelve a ser el caso de Result
printfn "%A" (checkFret 3)printfn "%A" (checkFret -1)printfn "%A" (PartialFailure.Warning "low confidence")Ok 3Error "negative fret"Warning "low confidence"TARS lo usa en otros sitios: AgentDefinition.fs marca su unión AgentSkill, cuyos casos Reasoning, Planning y Coding chocarían si no con los de AgentDomain y CapabilityKind. Añadirlo a una unión existente como PartialFailure rompe el código que la usa, que debe entonces calificar cada caso: es una decisión que conviene tomar al crear el tipo.
Puntos clave
Sección titulada «Puntos clave»- Una tupla (
string * int) agrupa valores sin nombre; las tuplas F# sonSystem.Tuple, ystruct (…)crea las tuplas de valor de C#. - Un record tiene campos con nombre e inmutables, igualdad y comparación estructurales, y
{ r with … }para copiarlo con cambios. - Una unión discriminada enumera todas las formas que puede tomar un valor, cada caso con sus datos: sustituye a las enumeraciones y a las pequeñas jerarquías de clases.
Option(Some/None) sustituye anull;Option.mapyOption.defaultValuetrabajan con ella, yOption.ofObjconvierte desde .NET.- Las uniones de un solo caso dan tipos distintos a los primitivos;
[<RequireQualifiedAccess>]evita que los casos de unión oculten otros nombres, comoResult.Error.
Ejercicios
Sección titulada «Ejercicios»- Declara un record
Tuningcon un nombre, una lista de notas (string list) y un traste de cejilla opcional. Crea la afinación estándar y luego, copiándola, Drop D (la cuerda grave bajada a re2) y la afinación estándar con una cejilla en el traste 2. Imprime Drop D y comprueba con=que la versión con cejilla es igual a una segunda copia hecha de la misma forma.
Solución
type Tuning = { Name: string; Notes: string list; Capo: int option }
let standard = { Name = "Standard"; Notes = [ "E2"; "A2"; "D3"; "G3"; "B3"; "E4" ]; Capo = None }let dropD = { standard with Name = "Drop D"; Notes = [ "D2"; "A2"; "D3"; "G3"; "B3"; "E4" ] }let capoOn2 = { standard with Capo = Some 2 }
printfn "%A" dropDprintfn "%b" (capoOn2 = { standard with Capo = Some 2 })printfn "%b" (standard.Notes = capoOn2.Notes){ Name = "Drop D" Notes = ["D2"; "A2"; "D3"; "G3"; "B3"; "E4"] Capo = None }truetrueLa igualdad llega hasta el fondo: las listas se comparan elemento a elemento, y Some 2 = Some 2. capoOn2 comparte su lista Notes con standard, lo que no supone ningún riesgo porque ninguno de los dos puede cambiarla.
- Un lick de guitarra es una secuencia de técnicas: una nota pulsada, un hammer-on de un traste a otro, un slide de un traste a otro o un bend de un número de semitonos (1.0 para un tono). Modélalo con una unión, escribe un lick de cuatro técnicas e imprímelo. ¿Un slide de 7 a 9 es igual a un slide de 9 a 7?
Solución
type Technique = | Picked | HammerOn of fromFret: int * toFret: int | Slide of fromFret: int * toFret: int | Bend of semitones: float
let lick = [ Picked; HammerOn(5, 7); Slide(fromFret = 7, toFret = 9); Bend 1.0 ]printfn "%A" lickprintfn "%b" (Slide(7, 9) = Slide(9, 7))[Picked; HammerOn (5, 7); Slide (7, 9); Bend 1.0]falseLos valores se comparan en orden, así que los dos slides son distintos, como en la guitarra. HammerOn(5, 7) y Slide(5, 7) también son distintos, aunque llevan los mismos datos: el caso forma parte del valor.
- Con el
ChordAsty elChordRendererde GA (cárgalos como en Código real: el AST de acordes de GA), construye los acordesBbmaj7/DyEaug, y renderízalos. Fíjate en cómo escribe GACmaj9más arriba: ¿dónde vamaj7?
Solución
exercises/l03_ex_ga_chord.fsx:
#load "../external/ga/ChordAst.fs" "../external/ga/ChordRenderer.fs"
open GA.Business.DSL.Typesopen GA.Business.DSL.Generators
let bFlatMajor7OverD = { Root = "B" RootAccidental = Flat Quality = None Components = [ Extension "maj7" ] Bass = Some("D", Natural) }
let eAugmented = { bFlatMajor7OverD with Root = "E"; RootAccidental = Natural; Quality = Some Augmented; Components = []; Bass = None }
printfn "%s" (ChordRenderer.render bFlatMajor7OverD)printfn "%s" (ChordRenderer.render eAugmented)
// El mismo acorde escrito con una calidad: mismo texto, árbol distintolet withQuality = { bFlatMajor7OverD with Quality = Some Major; Components = [ Extension "7" ] }printfn "%s %b" (ChordRenderer.render withQuality) (withQuality = bFlatMajor7OverD)Bbmaj7/DEaugBbmaj7/D falseEl bemol es la alteración de la fundamental, no parte de su letra. maj7 se puede escribir de dos formas: sin calidad y Extension "maj7", o Quality = Some Major con Extension "7". Ambas se renderizan como Bbmaj7/D, pero los dos árboles no son iguales. El parser de GA produce la primera forma: los comentarios de ChordParser.fs mantienen maj7 como un solo token. Dos árboles para un acorde significan que = puede considerar distinto un mismo acorde; la lección 12 trata del diseño de tipos en los que eso no puede ocurrir.