3. Tuples, records, unions et options
Sections
Réglages de lecture
Aucune voix pour cette langue dans ce navigateur.
Plus de voix
Code et termes techniques en anglais
Désactivé : la voix de la page lit tout, sans pause entre deux voix.
Aucune voix pour cette langue dans ce navigateur.
Plus de voix
D’autres voix : Microsoft Edge propose des voix naturelles en ligne, et Windows (Paramètres › Heure et langue › Voix), macOS et Android en installent d’autres.
Code : les scripts examples/l03_*.fsx et les extraits rejetés compile_fail/l03_*.fsx.
En C# et en Java, les données vivent en général dans des classes : des champs, un constructeur, Equals et GetHashCode si on y pense, et une hiérarchie de classes quand une valeur peut prendre plusieurs formes. Les records (C# 9, Java 16) et les hiérarchies scellées (C# avec des classes de base abstract, Java 17 avec sealed interface) ont raccourci tout cela. F# a des formes courtes pour tout cela depuis le début, et c’est la manière normale de modéliser les données :
| Vous voulez | C# | Java | F# |
|---|---|---|---|
| quelques valeurs ensemble, sans nom | (string, int) |
un record ou Map.Entry |
tuple string * int |
| des champs nommés, immuables, comparés par valeur | record |
record |
record |
| une forme parmi plusieurs, chacune avec ses données | classe de base abstraite et sous-classes | sealed interface et records |
union discriminée |
| une valeur qui peut manquer | null, Nullable<T> |
null, Optional<T> |
Option<'T> |
// Un tuple regroupe des valeurs sans déclarer de type : ici string * intlet lowString = ("E", 2)printfn "%A" lowStringprintfn "%s" (fst lowString)
// Déconstruction, comme var (note, octave) = ... en C#let (note, octave) = lowStringprintfn "%s%d" note octave
// Une fonction renvoie plusieurs valeurs dans un tuplelet octavesAndSemitones interval = (interval / 12, interval % 12)let octaves, semitones = octavesAndSemitones 29 // les parenthèses sont facultativesprintfn "29 semitones = %d octaves and %d semitones" octaves semitones
// Les tuples F# sont des objets System.Tuple ; les tuples struct sont des System.ValueTuple, les tuples 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- Le type de
("E", 2)s’écritstring * int: le*se lit « et », comme dans un produit de types. fstetsndrenvoient le premier et le deuxième élément d’une paire.- La dernière ligne montre une différence avec C# : un tuple F# est un
System.Tuple, un objet sur le tas, alors que le(string, int)de C# est unSystem.ValueTuple, une struct.struct ("A", 4)crée un tuple valeur, celui à utiliser pour appeler du code C# qui en attend un. La page sur les tuples donne les détails.
Un tuple convient pour deux ou trois valeurs qui voyagent ensemble un moment. Dès que les valeurs ont un nom dans votre tête, donnez-leur un nom dans le code : un record.
type GuitarString = { Note: string; Octave: int; Gauge: float }
let low = { Note = "E"; Octave = 2; Gauge = 0.046 } // les étiquettes indiquent le type à F#let high = { low with Octave = 4; Gauge = 0.010 } // copie et mise à jour, comme with en C#
printfn "%s%d, gauge %.3f" low.Note low.Octave low.Gaugeprintfn "%A" high
// Égalité et comparaison structurelles, générées par le compilateurprintfn "%b" (high = { Note = "E"; Octave = 4; Gauge = 0.010 })printfn "%b" (low = high)printfn "%b" (low < high) // champs comparés dans l'ordre : Note, puis OctaveE2, gauge 0.046{ Note = "E" Octave = 4 Gauge = 0.01 }truefalsetruetype GuitarString = { … }déclare un record. L’équivalent C# estrecord GuitarString(string Note, int Octave, double Gauge);.- La valeur
{ Note = "E"; Octave = 2; Gauge = 0.046 }ne contient pas de nom de type : F# trouve le type de record à partir de ses étiquettes. { low with Octave = 4; Gauge = 0.010 }est une copie dont deux champs changent, lelow with { Octave = 4, Gauge = 0.010 }de C#.=compare les champs, et<les compare dans l’ordre,Noted’abord. Les records C# ont l’égalité par valeur mais pas d’ordre ; F# génère les deux, plus un code de hachage.
Chaque champ doit être fourni, et aucun champ ne peut être modifié :
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 est le module dans lequel F# Interactive enveloppe le script. Un record C# à paramètres positionnels se comporte de la même façon, mais un record C# peut aussi avoir des propriétés set, et un new C# peut laisser une propriété à sa valeur par défaut. Un record F# a toujours toutes ses valeurs, dès le départ.
Unions discriminées
Section intitulée « Unions discriminées »Un accord de do majeur à la guitare se joue x32010 : la corde de mi grave est étouffée, puis case 3, case 2, une corde à vide, case 1, une corde à vide. Chaque corde est dans une situation parmi plusieurs, et chaque situation a des données différentes. En C#, vous écririez une petite hiérarchie de classes :
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#, c’est un seul type, une union discriminée :
// Une union discriminée : une valeur est exactement l'un des castype Accidental = | Natural | Sharp | Flat
// Chaque cas peut porter ses propres donnéestype Fingering = | Open | Fretted of fret: int | Barre of fret: int * strings: int | Muted
// Do majeur, de la corde de mi grave à la corde de mi aigu : x32010let cMajor = [ Muted; Fretted 3; Fretted 2; Open; Fretted 1; Open ]printfn "%A" cMajor
let fMajor = Barre(fret = 1, strings = 6) // champs nommésprintfn "%A" fMajorprintfn "%b %b" (Fretted 3 = Fretted 3) (Sharp = Flat)[Muted; Fretted 3; Fretted 2; Open; Fretted 1; Open]Barre (1, 6)true falseAccidentala des cas sans données : c’est une énumération, mais fermée. UnenumC# accepte(Accidental)42; une valeur d’union ne peut être que l’un de ses cas.Fretted of fret: intporte unint, nomméfret.Barre of fret: int * strings: intporte deux valeurs. Les noms sont facultatifs, et utiles : ils apparaissent dans les info-bulles et permettentBarre(fret = 1, strings = 6).- Chaque cas est aussi une fonction constructeur :
Fretted 3construit une valeur de typeFingering, etFrettedseul est une fonctionint -> Fingering. - L’égalité et la comparaison sont générées, comme pour les records.
Le véritable avantage apparaît avec le filtrage par motif, dans la leçon 4 : le compilateur connaît la liste complète des cas et avertit quand un match en oublie un. Un switch C# sur la hiérarchie de records ne peut pas savoir que personne n’a ajouté une cinquième sous-classe dans un autre fichier.
Du vrai code : l’AST des accords de GA
Section intitulée « Du vrai code : l’AST des accords de GA »Guitar Alchemist analyse les symboles d’accords comme F#m7b5/C en un arbre syntaxique. Les types de cet arbre sont dans ChordAst.fs, et ce sont des records et des unions :
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 }Vingt-sept lignes décrivent tous les symboles d’accords que GA comprend. Les types s’imbriquent : un record ChordAst contient une liste d’unions ChordComponent, dont l’un des cas contient un AccidentalType et une chaîne. Quality: QualityType option dit qu’un accord peut n’avoir aucune qualité écrite (C7), et Bass: (string * AccidentalType) option qu’il peut n’avoir aucune basse, et qu’une basse est un tuple d’une lettre et d’une altération. La version C# ferait une douzaine de fichiers.
Le cours charge ce fichier et le moteur de rendu de GA, et construit F#m7b5/C à la main (examples/l03_ga_chords.fsx) :
// L'AST d'accords et le moteur de rendu de Guitar Alchemist, chargés depuis les copies de external/ga#load "../external/ga/ChordAst.fs" "../external/ga/ChordRenderer.fs"
open GA.Business.DSL.Typesopen GA.Business.DSL.Generators
// F#m7b5/C comme valeur du 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 affiche la valeur en syntaxe F#, que vous pouvez recoller dans un script. ChordRenderer.render retransforme l’arbre en texte ; la leçon 4 lit son code.
Option au lieu de null
Section intitulée « Option au lieu de null »Some Minor et None ci-dessus sont les deux cas d’Option, une union définie dans FSharp.Core :
type Option<'T> = | None | Some of 'Tint option est une autre façon d’écrire Option<int>. Un int option est soit Some 2, soit None, et le type le dit : le code qui le reçoit doit traiter les deux. Une string F#, en revanche, n’est pas censée être null.
// Option : Some valeur, ou None, au lieu 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
// Une méthode .NET peut renvoyer null : Option.ofObj le transforme 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.mapapplique une fonction à la valeur contenue dansSome, et laisseNonetel quel : lecapo?.ToString()de C#, mais pour n’importe quelle fonction.Option.defaultValueest le??de C#. Le moteur de rendu de GA utilise ces deux mêmes fonctions pour la qualité d’un accord :ast.Quality |> Option.map renderQuality |> Option.defaultValue "".Option.ofObjest la passerelle depuis les API .NET, qui renvoientnullpour « rien ».
None n’est pas un null déguisé que l’on pourrait oublier de vérifier : le type int option n’est pas int, donc on ne peut pas écrire capo + 1 sans dire ce qui se passe quand il n’y a pas de capodastre. Et null n’est pas une option :
let capo: int option = nulll03_option_null.fsx(1,24): error FS0043: The type 'int option' does not have 'null' as a proper valueComparez avec l’Optional<T> de Java, une classe dont la variable peut elle-même être null, et avec les types référence nullables de C#, des avertissements ajoutés par-dessus des types qui acceptent toujours null. F# 9 a ajouté les types référence nullables pour l’interopérabilité avec les API .NET ; la leçon 9 les couvre.
Unions à un seul cas
Section intitulée « Unions à un seul cas »Une union à un seul cas enveloppe une valeur dans un type à part. TARS donne ainsi des types distincts à ses identifiants, dans Primitives.fs :
/// Unique identifier for an agenttype AgentId = AgentId of Guid
/// Unique identifier for a correlation/conversationtype CorrelationId = CorrelationId of GuidLes deux contiennent un Guid, mais ils ne sont pas interchangeables :
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'Avec de simples paramètres Guid, le compilateur aurait accepté l’appel. let describeAgent (AgentId id) = … déballe la valeur dans le paramètre lui-même, un motif qu’explique la leçon 4 (examples/l03_single_case.fsx exécute l’appel correct). En C#, le plus proche est readonly record struct AgentId(Guid Value) : plus de cérémonie, la même sécurité.
Quand des noms de cas entrent en collision
Section intitulée « Quand des noms de cas entrent en collision »Les cas d’union sont des noms dans la portée, comme les fonctions. Si deux unions ont un cas du même nom, celle définie en dernier l’emporte. Ok et Error sont les cas du type Result de FSharp.Core (leçon 7), toujours dans la portée : une union qui a son propre cas Error masque donc Result.Error pour toutes les lignes en dessous. Voici la situation réduite à quelques lignes :
// Réduit depuis TARS (v2/src/Tars.Core/Domain.fs) : une union avec un cas nommé 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" a construit un PartialFailure, pas un Result. TARS a exactement ce cas. Domain.fs, le huitième fichier de Tars.Core, déclare :
/// 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 listL’effet suit l’ordre des fichiers de la leçon 1. Budget.fs, compilé avant Domain.fs, écrit un simple Error e et obtient Result.Error. Les fichiers compilés après doivent qualifier : ConstitutionLoader.fs écrit Result.Error, et ArcTypes.fs va jusqu’à FSharp.Core.Result.Error. À ce commit, v2/src contient 342 occurrences de Result.Error. Failure entre en collision de la même façon : l’union Performative, quelques lignes plus haut, a elle aussi un cas Failure, et l’AgentWorkflow.fs de TARS écrit en entier ExecutionOutcome.Success, ExecutionOutcome.PartialSuccess et ExecutionOutcome.Failure.
La réponse de F# est l’attribut RequireQualifiedAccess : les cas de l’union doivent alors s’écrire avec son nom, et ne masquent donc rien.
// RequireQualifiedAccess : les cas doivent s'écrire PartialFailure.Warning et 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 est de nouveau le cas de Result
printfn "%A" (checkFret 3)printfn "%A" (checkFret -1)printfn "%A" (PartialFailure.Warning "low confidence")Ok 3Error "negative fret"Warning "low confidence"TARS l’utilise ailleurs : AgentDefinition.fs marque son union AgentSkill, dont les cas Reasoning, Planning et Coding entreraient sinon en collision avec ceux d’AgentDomain et de CapabilityKind. L’ajouter à une union existante comme PartialFailure casse le code qui l’utilise, qui doit alors qualifier chaque cas : c’est une décision à prendre à la création du type.
À retenir
Section intitulée « À retenir »- Un tuple (
string * int) regroupe des valeurs sans nom ; les tuples F# sont desSystem.Tuple, etstruct (…)crée les tuples valeur de C#. - Un record a des champs nommés et immuables, l’égalité et la comparaison structurelles, et
{ r with … }pour le copier avec des modifications. - Une union discriminée liste toutes les formes que peut prendre une valeur, chaque cas avec ses données : elle remplace les énumérations et les petites hiérarchies de classes.
Option(Some/None) remplacenull;Option.mapetOption.defaultValuetravaillent avec,Option.ofObjconvertit depuis .NET.- Les unions à un seul cas donnent des types distincts aux primitives ;
[<RequireQualifiedAccess>]empêche les cas d’union de masquer d’autres noms, commeResult.Error.
Exercices
Section intitulée « Exercices »- Déclarez un record
Tuningavec un nom, une liste de notes (string list) et une case de capodastre facultative. Créez l’accordage standard, puis Drop D (la corde grave descendue en ré2) et l’accordage standard avec un capodastre en case 2, en le copiant. Affichez Drop D, et vérifiez avec=que la version avec capodastre est égale à une seconde copie faite de la même façon.
Solution
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 }truetrueL’égalité descend jusqu’au bout : les listes sont comparées élément par élément, et Some 2 = Some 2. capoOn2 partage sa liste Notes avec standard, ce qui ne pose aucun problème puisque ni l’un ni l’autre ne peut la modifier.
- Un plan de guitare est une suite de techniques : une note piquée, un hammer-on d’une case à une autre, un glissé d’une case à une autre, ou un bend d’un nombre de demi-tons (1.0 pour un ton). Modélisez-le avec une union, écrivez un plan de quatre techniques et affichez-le. Un glissé de 7 à 9 est-il égal à un glissé de 9 à 7 ?
Solution
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]falseLes valeurs sont comparées dans l’ordre : les deux glissés diffèrent donc, comme sur la guitare. HammerOn(5, 7) et Slide(5, 7) diffèrent aussi, bien qu’ils portent les mêmes données : le cas fait partie de la valeur.
- Avec le
ChordAstet leChordRendererde GA (chargez-les comme dans Du vrai code : l’AST des accords de GA), construisez les accordsBbmaj7/DetEaug, et rendez-les. Regardez comment GA écritCmaj9plus haut : où vamaj7?
Solution
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)
// Le même accord écrit avec une qualité : même texte, arbre différentlet withQuality = { bFlatMajor7OverD with Quality = Some Major; Components = [ Extension "7" ] }printfn "%s %b" (ChordRenderer.render withQuality) (withQuality = bFlatMajor7OverD)Bbmaj7/DEaugBbmaj7/D falseLe bémol est l’altération de la fondamentale, pas une partie de sa lettre. maj7 peut s’écrire de deux façons : sans qualité et Extension "maj7", ou Quality = Some Major avec Extension "7". Les deux rendent Bbmaj7/D, mais les deux arbres ne sont pas égaux. Le parseur de GA produit la première forme : les commentaires de ChordParser.fs gardent maj7 comme un seul jeton. Deux arbres pour un accord signifient que = peut déclarer différent un même accord ; la leçon 12 porte sur la conception de types où cela ne peut pas arriver.