Option : une commande qui n'existe pas
Durée indicative : environ 12 min.
Le problème que ce module résout
Un client demandera tôt ou tard « où en est ma commande 9999 ? », et la commande 9999 n'existe pas. La fonction qui cherche une commande doit pouvoir répondre « rien trouvé » — et le code qui l'appelle ne doit pas pouvoir oublier ce cas.
Ce module, toujours sans modèle, termine commandes.fsx avec la fonction getOrder que l'agent exposera à partir du module 5.
Ce que vous écririez en C#
Commande? TrouverCommande(int id) =>
commandes.FirstOrDefault(c => c.Id == id);
var statut = TrouverCommande(9999).Statut; // NullReferenceException
L'absence est encodée par null. Les annotations de nullabilité de C# aident, mais restent des avertissements que l'on peut ignorer ou supprimer avec !, et elles ne s'appliquent pas de la même façon à tout le code existant.
Option : une union que vous connaissez déjà
F# représente une valeur peut-être absente avec le type Option. Sa définition, simplifiée, tient en trois lignes — c'est une union discriminée comme celle du module précédent :
type Option<'T> =
| Some of 'T
| None
Une Commande option (on écrit généralement le type option après, comme int list) est donc soit Some avec une commande, soit None. Pour lire la commande, il faut passer par un match — et le compilateur vous signalera un FS0025 si vous oubliez None, exactement comme pour Statut.
La bibliothèque standard renvoie déjà des options là où C# renverrait null ou lèverait une exception. Pour une Map, c'est Map.tryFind :
Map.tryFind 4512 commandes // Some { Id = 4512; ... }
Map.tryFind 9999 commandes // None
Exercice — écrire la recherche et getOrder
Ajoutez à la fin de commandes.fsx deux fonctions :
trouverCommande (id: int) : Commande option, qui cherche la commande dans laMapcommandes;getOrder (orderId: int) : string, qui renvoie la description de la commande (avec la fonctiondecriredu module 3) si elle existe, et sinon exactementCommande <numéro> introuvable.
Contraintes : pas de null, et pas d'Option.get ni de .Value — ces deux raccourcis lèvent une exception sur None, ce qui ramène exactement le problème de FirstOrDefault.
Pour vérifier, créez recherche.fsx :
// recherche.fsx — module 04 : chercher des commandes qui existent… ou pas.
#load "commandes.fsx"
open Commandes
printfn "%A" (trouverCommande 7781)
printfn "%A" (trouverCommande 9999)
for numero in [ 4512; 7781; 9999 ] do
printfn "%s" (getOrder numero)
dotnet fsi recherche.fsx
Sortie attendue :
Some { Id = 7781
Statut = EnPreparation }
None
Commande 4512 : expédiée, suivi TRK-88
Commande 7781 : en préparation
Commande 9999 introuvable
Les deux premières lignes montrent les valeurs brutes, telles que %A les affiche : un Some qui enveloppe le record, puis None. Les trois suivantes sont les textes que le modèle recevra quand il demandera l'outil.
Si vous bloquez : dans getOrder, faites un match sur trouverCommande orderId, avec une branche Some commande -> ... et une branche None -> ....
Solution
// Module 04 — Option : une recherche qui peut ne rien trouver.
let trouverCommande (id: int) : Commande option =
Map.tryFind id commandes
// La fonction que le modèle pourra demander d'appeler (modules 05 à 09).
let getOrder (orderId: int) : string =
match trouverCommande orderId with
| Some commande -> decrire commande
| None -> $"Commande {orderId} introuvable"
Une variante sans match : Option.map et Option.defaultValue
Le module Option offre des fonctions qui traitent les deux cas pour vous. La même getOrder peut s'écrire en pipeline :
let getOrder (orderId: int) : string =
trouverCommande orderId
|> Option.map decrire
|> Option.defaultValue $"Commande {orderId} introuvable"
Option.map decrireappliquedecrireseulement s'il y a une commande :Some commandedevientSome "Commande ...", etNoneresteNone;Option.defaultValue textesort la valeur duSome, ou renvoietextesurNone.
Les deux écritures sont équivalentes. Le match est plus explicite quand vous débutez ; le pipeline devient naturel quand les étapes s'enchaînent. Gardez la version de votre choix : les modules suivants n'utilisent que la signature getOrder : int -> string.
Ce que vous venez de faire
Vous avez représenté « peut-être une commande » par le type Commande option, produit ce type avec Map.tryFind, et consommé les deux cas sans null ni accès non vérifié — avec un match, puis avec Option.map et Option.defaultValue. Vous avez aussi constaté qu'Option n'est pas un mécanisme à part : c'est une union discriminée à deux cas. Votre commandes.fsx est désormais complet ; il ne changera plus.
Au module suivant, vous reprenez le modèle du module 2 et vous lui donnez getOrder comme outil. Vous allez voir, avec les vrais types .NET, l'échange du module 1 : le modèle demande, votre code exécute.
Vérifiez votre compréhension
Quatre versions de `prixDe (sku: string) : string` utilisent `Map.tryFind sku tarifs`. Laquelle traite correctement un SKU absent, sans risque d'exception ni valeur sentinelle ?
Avec `let codesPromo = Map [ ("NOEL", 10) ]` et `let remise code = Map.tryFind code codesPromo`, que vaut `remise "ETE" |> Option.map (fun pct -> pct * 2) |> Option.defaultValue 0` ?
Vous écrivez `match trouverClient id with | Some c -> c.Nom` et oubliez la branche `None`. Que vous dit le compilateur, et pourquoi ?
Envie d'être prévenu des prochains modules ?
L'Académie reste gratuite et en accès libre, sans inscription. Si vous voulez juste être averti par email à la sortie d'un nouveau module, c'est ici — aucune obligation, désinscription en un clic.