Explorons un cas concret de modélisation de Value Object centré sur une mesure physique pour un projet industriel.
Préambule
Dans le cadre de mes missions, j'utilise très souvent ces objets. Ma mission concernant un projet industriel dédié au développement d'un logiciel de dessin technique automatisé, n'a pas fait exception, bien au contraire.
Cet article a pour but de partager la méthode de modélisation que nous avons mise en œuvre mais transposée aux mesures de température.
Le pattern Value Object
Avant de plonger dans les détails de l'implémentation, il est pertinent de revoir brièvement la notion de value objects.
Les value objects se distinguent par le fait qu'ils sont définis par la valeur de leurs attributs. Autrement dit, deux value objects sont considérés comme équivalents si tous leurs attributs sont identiques. Cette caractéristique les oppose aux entités, qui sont identifiées de manière unique, au moyen d'un identifiant spécifique - souvent dénommé Id
.
L'adoption de value objects dans la conception logicielle présente plusieurs avantages:
- Ils permettent d'encapsuler et de valider des données complexes, renforçant ainsi l'intégrité et la fiabilité du domaine métier.
- Leur immuabilité — l'impossibilité de modifier leur état une fois créés — contribue également à la sûreté et à la stabilité de l'application.
- En incarnant des concepts métier spécifiques sous forme d'objets, ils facilitent la compréhension du domaine et améliorent la communication entre développeurs et experts métier.
Contexte métier
Dans les industries chimiques, pétrochimiques, ou dans le domaine de l'ingénierie thermique, l'équation PV=nRT
, connue sous le nom de loi des gaz parfaits, est fréquemment utilisée pour prédire le comportement des gaz sous différentes conditions de température, de pression et de volume. Cette équation relie la pression (P), le volume (V), le nombre de moles (n) de gaz, la constante des gaz parfaits (R), et la température (T) pour décrire l'état d'une certaine quantité de gaz idéal.
Cette équation peut, par exemple, être utilisée pour dimensionner des réacteurs, des réservoirs de stockage ou des systèmes de conduites en fonction des conditions opérationnelles attendues.
Implémentations
Implémentation naïve
Partons sur une implémentation naïve du calcul du volume d'un gaz en nous basant sur cette équation.
Imaginons que nous souhaitons calculer le volume occupé par une certaine quantité de gaz à une pression et une température données, en utilisant l'équation des gaz parfaits. Voici un exemple d'implémentation:
public static class GasVolumeCalculator { private const double R = 8.314; // Constante des gaz parfaits J/(mol.K) // Calcul du volume d'un gaz parfait à partir de P, n, et T public static double Calculate(double pressure, double moles, double temperature) { // Assurez-vous que la température est en Kelvin pour cette équation double volume = (moles * R * temperature) / pressure; return volume; } }
public static void Main(string[] args) { double pressure = double.Parse(args[0]); // Convertit le premier argument en double pour la pression double moles = double.Parse(args[1]); // Convertit le deuxième argument en double pour les moles double temperature = double.Parse(args[2]); // Convertit le troisième argument en double pour la température double volume = GasVolumneCalculator.Calculate(pressure, moles, temperature); Console.WriteLine($"Le volume occupé par le gaz est de {volume} mètres cubes."); }
Ci-dessous un exemple d'utilisation du programme:
> GasVolumeCalculator 101325 1 298 Le volume occupé par le gaz est de 0.024451734517641256 mètres cubes.
Dans le cadre d'un projet simple cette implémentation est largement suffisante.
Cependant si on imagine des processus chimiques complexes avec ses lots de calculs on imagine facilement les problèmes qui pourraient survenir.
Manque de précision
Dans le contexte de notre cas d'utilisation, le choix initial du type double
pour représenter des mesures de température et d'autres grandeurs physiques peut conduire à des problèmes de précision, notamment en raison de la manière dont les doubles
gèrent les nombres à virgule flottante et les erreurs d'arrondi associées.
Si l'ensemble du système repose sur des doubles
pour effectuer des calculs, envisager ultérieurement de passer à decimal
pour améliorer la précision peut s'avérer extrêmement difficile: cette transition n'est pas seulement une question de changer le type de données ; elle implique également de revoir toutes les opérations mathématiques, car decimal
et double
n'ont pas les mêmes comportements pour les calculs.
Absence de validation
L'absence de mécanismes de validation intégrés lors de l'utilisation de types primitifs comme double
pour représenter des températures peut mener à des erreurs significatives, notamment en ignorant des contraintes physiques fondamentales telles que le zéro absolu.
En physique, le zéro absolu est la température la plus basse possible, où aucune énergie thermique ne reste dans une substance. Cette température est de -273.15°C ou 0 Kelvin.
Sans une validation adéquate, rien n'empêche une saisie utilisateur d'assigner une valeur de température en dessous de ce seuil physique, ou même un calcul (!!!), conduisant à des résultats physiquement irréalistes et potentiellement à des erreurs dans le traitement des données.
Difficulté à gérer les unités de mesure
Les types primitifs comme double
ne portent pas d'information sur l'unité des mesures, ici de la température. Par exemple, une variable représentant une température en degrés Celsius pourrait accidentellement être confondue avec une température en Kelvin ou en Fahrenheit.
De plus, s'il y a de nombreuses unités qui coexistent dans les traitements, il y aura potentiellement besoin de nombreuses conversions d'unités qui pourraient poser des problèmes de précision (retour point 1).
Perte de sémantique
L'utilisation de types primitifs ne transmet pas le sens ou le contexte de la mesure: la variable k
représente-t-elle une température, une pression, une distance, ... ?
Naming ! Me direz-vous. C'est vraie, le bon nommage des variables est un point essentiel mais il n'est parfois pas suffisant: lors du passage de paramètre il est facile d'inverser l'ordre des paramètres et d'introduire ainsi une erreur.
Problèmes de réutilisabilité et de maintenance
Certaines opérations intrinsèques à la température (comme les conversions d'unités) vont se retrouver facilement dupliquée du fait qu'elles ne rencontrent pas de support. L'utilisation de helpers n'est d'ailleurs pas forcément la solution - nombre de projets dans lesquels j'ai retrouvé le même code dans différents helpers car ils n'apparaissent pas comme "support naturel" de l'opération.
Incapacité à encapsuler des comportements
Les types primitifs ne permettent pas d'encapsuler des comportements ou des règles métier spécifiques aux mesures, comme des conditions de validité ou des méthodes de conversion spéciales.
Risque d'incohérence dans les données
Dans des systèmes complexes avec de nombreuses interactions entre composants, l'absence d'une représentation forte des mesures augmente le risque d'incohérence des données, surtout lorsque différentes parties du système manipulent ces mesures de manière indépendante.
Seconde implémentation
Cette approche initiale, simple et directe, risque de conduire à un anti-pattern connu sous le nom de "primitive obsession" dans le contexte d'un projet complexe - si vous n'êtes pas familier avec ce concept, je vous recommande de consulter cet article.
Pour améliorer cette mise en œuvre, nous pouvons adopter le pattern objet valeur, qui offre une solution plus élaborée ( - spoiler alert - cette implémentation basique n'est pas encore satisfaisante, mais faisons par étape).
public enum TemperatureUnit { Celsius, Fahrenheit, Kelvin, Rankine } public sealed class Temperature { public double Value { get; private set; } public TemperatureUnit Unit { get; private set; } public Temperature(double value, TemperatureUnit unit) { if (unit == TemperatureUnit.Kelvin && value < 0) { throw new ArgumentException("La température ne peut pas être inférieure au zéro absolu (0 Kelvin)."); } // autres cas de validation Value = value; Unit = unit; } public override string ToString() { return $"{Value} {Unit}"; } // ... }
Cette implémentation de la température en tant que value object offre une amélioration par rapport à l'utilisation de simples double
. Cependant elle présente encore des limitations dans notre contexte, notamment en raison de la gestion des unités.
L'intuitivité de manipuler des températures avec des unités explicites, comme le Celsius, le Fahrenheit ou le Kelvin, cache en réalité une complexité sous-jacente lorsqu'il s'agit de réaliser des calculs impliquant plusieurs de ces objets. Chaque opération entre températures de différentes unités nécessite des conversions préalables, augmentant ainsi la perte de précision.
De plus, la vérification systématique du zéro absolu, bien qu'essentielle pour garantir la validité des données, introduit une complexité supplémentaire. Cette vérification, pour être exhaustive, requiert soit une série de conditions préalables pour chaque unité, soit une conversion systématique en Kelvin uniquement pour effectuer cette vérification. Dans les deux cas, cela alourdit l'implémentation et peut impacter les performances pour des vérifications qui, dans la pratique, ne serviront que rarement.
Troisième implémentation
La sélection des unités de température est principalement dictée par le contexte d'utilisation, comme la localisation géographique de l'utilisateur ou le contexte spécifique de la mesure.
Toutefois, les experts du domaine privilégient la plupart du temps une unité standard pour simplifier leurs calculs. En général le Kelvin est leur unité de référence. Sur cette base, adoptons le pattern de la méthode de fabrique pour créer nos instances d'objets Temperature
, facilitant ainsi une gestion cohérente et centralisée des conversions de température.
public sealed class Temperature { public static Temperature FromKelvin(double kelvin) { return new Temperature(kelvin); } public static Temperature FromCelsius(double celsius) { double kelvin = celsius + 273.15; return new Temperature(kelvin); } public static Temperature FromFahrenheit(double fahrenheit) { double kelvin = (fahrenheit - 32) * 5/9 + 273.15; return new Temperature(kelvin); } // Ajoutez ici des méthodes de fabrique pour d'autres unités si nécessaire private double _value; private Temperature(double value) { if (value < 0) { throw new ArgumentException("La température ne peut pas être inférieure au zéro absolu (0 Kelvin)."); } _value = value; } public double ToKelvin() { return _value ; } public double ToCelsius() { return _value - 273.15; } public double ToFahrenheit() { return (_value - 273.15) * 9/5 + 32; } // Ajoutez ici des méthodes de conversion pour d'autres unités si nécessaire public override string ToString() { return $"{KelvinValue} K"; } }
L'implémentation présentée ci-dessus s'aligne davantage sur un standard de code prêt pour la production.
En rendant le constructeur privé et en favorisant l'emploi de méthodes statiques, nous clarifions nettement les conversions dès l'introduction du cas d'utilisation.
De surcroît, les méthodes de conversion d'instance, intégrées directement au sein du value object Temperature
plutôt que dans un helper externe, manifestent explicitement l'intention de conversion à la conclusion du cas d'usage.
Le champ _value
est désormais inaccessible directement, ce qui encourage l'usage explicite des méthodes de conversion. Vous pourriez vous demander alors comment réaliser des opérations telles que les additions, multiplications, etc., avec une température.
Amélioration: surcharge des opérateurs et méthodes associées
La quatrième étape de notre implémentation du type Temperature
montre l'implémentation d'opérateurs et de méthodes qui permettent de ne pas exposer la valeur sous-jacentes de la tempétature (encapsulation).
public static TemperatureDelta operator -(Temperature left, Temperature right) => TemperatureDelta.Subtract(left, right); public static double operator /(Temperature numerator, Temperature denumerator) => numerator._kelvin / denumerator._kelvin; public static bool operator ==(Temperature left, Temperature right) => left._kelvin == right._kelvin; public static bool operator !=(Temperature left, Temperature right) => !(left == right); public static bool operator <(Temperature left, Temperature right) => left._kelvin < right._kelvin; public static bool operator >(Temperature left, Temperature right) => left._kelvin > right._kelvin; public static bool operator <=(Temperature left, Temperature right) => left._kelvin <= right._kelvin; public static bool operator >=(Temperature left, Temperature right) => left._kelvin >= right._kelvin; public Temperature Apply(TemperatureDelta delta) { return delta.ApplyTo(this); } public TemperatureDelta GetDeltaWith(Temperature other) { return TemperatureDelta.GetDelta(this, other); } public TemperatureDelta GetDeltaFromReference(Temperature reference) { return TemperatureDelta.GetDelta(reference, this); }
Il est désormais simple de travailler non plus avec un double mais directement avec un type Temperature
. Le code ci-dessous donne un exemple:
Temperature temp1 = Temperature.FromCelsius(25); Temperature temp2 = Temperature.FromFahrenheit(68); // soit 20°C TemperatureDelta delta = temp1 - temp2; Temperature temp3 = Temperature.FromCelsius(5); Temperature temp4 = temp3.Apply(delta); Console.WriteLine($"Delta : {delta.ToCelsius()}°C"); // Delta: 5°C Console.WriteLine($"Temp4 : {temp4.ToCelsius()}°C"); // Temp4: 0°C
ou bien encore :
Temperature hotSource = Temperature.FromCelsius(600); Temperature coldSource = Temperature.FromCelsius(30); double carnotEfficiency = 1.0 - (coldSource / hotSource); Console.WriteLine($"Carnot efficiency : {carnotEfficiency:P2}");
La question de la conversion ne se pose qu'en début de use case et en fin de use case - dans notre exemple simpliste, en début de méthode et en fin de méthode. Dans le cœur des traitements, nous sommes content de ne traiter que des températures, aucune charge cognitive n'est nécessaire afin de savoir en quelle unité on travaille, ce qui se passe en générale quand les experts métiers font leur calculs.
Profitant de l'ajout de ces opérateurs, abordons un aspect crucial des value objects qui n'a pas encore été traité : l'égalité basée sur les valeurs. Je suggère de réaliser cette implémentation en utilisant la bibliothèque Value. Vous devez d'abord ajouter cette bibliothèque à votre projet. Si vous utilisez .NET Core ou .NET Framework, vous pouvez le faire via la ligne de commande ou votre IDE préféré. Par exemple, en utilisant la ligne de commande dotnet
:
dotnet add package Value
Pour intégrer l'égalité basée sur les valeurs en utilisant la bibliothèque Value, il suffit d'adapter la classe Temperature
pour hériter de ValueType<Temperature>
:
public sealed class Temperature : ValueType<Temperature> { private readonly double _value; // .... protected override IEnumerable<object> GetAllAttributesToBeUsedForEquality() { return new object[] { _value }; } }
Amélioration de la précision
Nous utilisons actuellement le type primitif double
comme base pour notre implémentation. Cependant, il est facile d'imaginer des scénarios où les arrondis et la précision des calculs pourraient poser problème. L'avantage de notre encapsulation et des méthodes que nous avons intégrées est qu'elle nous permet, si nécessaire, de remplacer ce type primitif par un decimal
pour une précision accrue. Ainsi, dans le cadre de processus complexes, les calculs s'appuieraient sur des decimal
. Les conversions vers et depuis le type double
ne seraient nécessaires qu'aux points d'entrée et de sortie du cas d'utilisation, où les questions de précision sont moins critiques.
using System; using System.Collections.Generic; using Value; public sealed class Temperature : ValueType<Temperature> { private readonly decimal _value; // Utilisation de decimal pour une meilleure précision private Temperature(decimal value) { if (value < 0) { throw new ArgumentException("La température ne peut pas être inférieure au zéro absolu (0 Kelvin)."); } _value = value; } public static Temperature FromKelvin(double kelvin) => new Temperature((decimal)kelvin); public static Temperature FromCelsius(double celsius) => new Temperature((decimal)celsius + 273.15m); public static Temperature FromFahrenheit(double fahrenheit) => new Temperature(((decimal)fahrenheit - 32) * 5/9 + 273.15m); // Méthodes de conversion d'instance retournant des valeurs double public double ToKelvin() => (double)_value; public double ToCelsius() => (double)(_value - 273.15m); public double ToFahrenheit() => (double)((_value - 273.15m) * 9/5 + 32); // ... protected override IEnumerable<object> GetAllAttributesToBeUsedForEquality() { return new List<object>() { _value }; } }
Quid du zéro absolu ?
Le zéro absolu est un élément important dans notre domaine, rendre sa présence explicite est extrêmement pertinente.
public sealed class Temperature: ValueType<Temperature> { private const decimal ABSOLUTE_ZERO_KELVIN = 0; public static readonly Temperature AbsolutZero = Temperature.FromKelvin(ABSOLUTE_ZERO_KELVIN); //public static readonly Temperature MaxValue = new Temperature(decimal.MaxValue); private Temperature(decimal value) { if (value < ABSOLUTE_ZERO_KELVIN) { throw new ArgumentException("La température ne peut pas être inférieure au zéro absolu."); } _value = value; } }
Plutôt que d'utiliser le classique nommage MinValue
de .Net il est important d'utiliser un nom de domaine, ici AbsolutZero
. Il est également envisageable de proposer la valeur MaxValue
(technique quant à elle) si nécessaire.
Implémentation finale
Avant de finaliser et soumettre ma version, qui pourrait différer de la vôtre pour s'adapter spécifiquement à votre secteur, une ultime révision est nécessaire.
Vous avez également pu remarquer l'utilisation de magic numbers dans le code. C'est quelque chose qui ne devrait pas exister. Ces valeurs ont une signification précise dans notre domaine. Explicitons-le!
Pour ceux qui ne souhaite pas utiliser la librairie Value
je vais donner une implémentation sans l'utilisation de celle-ci.
Enfin, pour simplifier ce code, je ne vais plus traité avec les double
.
Avec ces dernières modifications nous avons désormais une implémentation assez complète de notre value object Temperature
. Il pourra être enrichi si nécessaire par d'autres méthodes tout en faisant attention de ne pas cassé l'immutabilité ou/et l'encapsulation. Voici le code complet:
public readonly struct Temperature : IEquatable<Temperature>, IComparable<Temperature> { public const decimal FahrenheitToCelsiusRatio = 5.0m / 9.0m; private const decimal ABSOLUTE_ZERO_KELVIN = 0.0m; private const decimal CELSIUS_TO_KELVIN_CONVERSION_POINT = 273.15m; private const decimal FREEZING_POINT_OF_WATER_IN_FAHRENHEIT = 32.0m; public static readonly Temperature AbsolutZero = new(ABSOLUTE_ZERO_KELVIN); public static readonly Temperature MaxValue = new(decimal.MaxValue); public static decimal operator /(Temperature numerator, Temperature denominator) { if (denominator._kelvin == 0) { throw new DivideByZeroException(nameof(denominator)); } return numerator._kelvin / denominator._kelvin; } public static TemperatureDelta operator -(Temperature left, Temperature right) { return left.GetDeltaWith(right); } public static bool operator ==(Temperature left, Temperature right) { return left._kelvin == right._kelvin; } public static bool operator !=(Temperature left, Temperature right) { return !(left == right); } public static bool operator <(Temperature left, Temperature right) { return left._kelvin < right._kelvin; } public static bool operator >(Temperature left, Temperature right) { return left._kelvin > right._kelvin; } public static bool operator <=(Temperature left, Temperature right) { return left._kelvin <= right._kelvin; } public static bool operator >=(Temperature left, Temperature right) { return left._kelvin >= right._kelvin; } public static Temperature FromKelvin(decimal kelvin) { return new Temperature(kelvin); } public static Temperature FromCelsius(decimal celsius) { decimal kelvin = celsius + CELSIUS_TO_KELVIN_CONVERSION_POINT; return FromKelvin(kelvin); } public static Temperature FromFahrenheit(decimal fahrenheit) { decimal celsius = (fahrenheit - FREEZING_POINT_OF_WATER_IN_FAHRENHEIT) * FahrenheitToCelsiusRatio; decimal kelvin = celsius + CELSIUS_TO_KELVIN_CONVERSION_POINT; return FromKelvin(kelvin); } private readonly decimal _kelvin; private Temperature(decimal kelvin) { if (kelvin < ABSOLUTE_ZERO_KELVIN) { throw new ArgumentOutOfRangeException(nameof(kelvin), "Temperature cannot be below absolute zero (0 K)."); } _kelvin = kelvin; } public decimal ToKelvin() { return _kelvin; } public decimal ToCelsius() { decimal celsius = _kelvin - CELSIUS_TO_KELVIN_CONVERSION_POINT; return celsius; } public decimal ToFahrenheit() { decimal celsius = _kelvin - CELSIUS_TO_KELVIN_CONVERSION_POINT; decimal fahrenheit = celsius / FahrenheitToCelsiusRatio + FREEZING_POINT_OF_WATER_IN_FAHRENHEIT; return fahrenheit; } public Temperature Apply(TemperatureDelta delta) { return delta.ApplyTo(this); } public TemperatureDelta GetDeltaWith(Temperature other) { return TemperatureDelta.GetDelta(this, other); } public TemperatureDelta GetDeltaFromReference(Temperature reference) { return TemperatureDelta.GetDelta(reference, this); } public int CompareTo(Temperature other) { return _kelvin.CompareTo(other._kelvin); } public bool Equals(Temperature other) { return this == other; } public override bool Equals(object? obj) { return obj is Temperature t && Equals(t); } public override int GetHashCode() { return _kelvin.GetHashCode(); } public override string ToString() { return $"{_kelvin:N2} K"; } public bool IsEqualTo(Temperature other, decimal tolerance) { return Math.Abs(_kelvin - other._kelvin) < tolerance; } public int CompareTo(Temperature other, decimal tolerance) { decimal delta = _kelvin - other._kelvin; if (Math.Abs(delta) < tolerance) { return 0; } return delta < 0 ? -1 : 1; } public static IEqualityComparer<Temperature> GetComparer(decimal tolerance) { return new TolerantEqualityComparer(tolerance); } private sealed class TolerantEqualityComparer : IEqualityComparer<Temperature> { #region Fields declarations private readonly decimal _tolerance; #endregion #region Constructors declarations public TolerantEqualityComparer(decimal tolerance) { _tolerance = tolerance; } #endregion public bool Equals(Temperature x, Temperature y) { return x.IsEqualTo(y, _tolerance); } public int GetHashCode(Temperature obj) { return Math.Round(obj._kelvin / _tolerance).GetHashCode(); } } }
Et voici le code de TemperatureDelta
:
public readonly struct TemperatureDelta : IEquatable<TemperatureDelta>, IComparable<TemperatureDelta> { private readonly decimal _deltaInKelvin; private TemperatureDelta(decimal deltaInKelvin) { _deltaInKelvin = deltaInKelvin; } public static TemperatureDelta FromKelvin(decimal delta) { return new TemperatureDelta(delta); } public static TemperatureDelta FromCelsius(decimal delta) { return new TemperatureDelta(delta); // ΔK = Δ°C } public static TemperatureDelta FromFahrenheit(decimal delta) { return new TemperatureDelta(delta * Temperature.FahrenheitToCelsiusRatio); } public static TemperatureDelta GetDelta(Temperature reference, Temperature other) { return FromKelvin(reference.ToKelvin() - other.ToKelvin()); } public static TemperatureDelta operator +(TemperatureDelta left, TemperatureDelta right) { return FromKelvin(left._deltaInKelvin + right._deltaInKelvin); } public static TemperatureDelta operator -(TemperatureDelta left, TemperatureDelta right) { return FromKelvin(left._deltaInKelvin - right._deltaInKelvin); } public static bool operator ==(TemperatureDelta l, TemperatureDelta r) { return l._deltaInKelvin == r._deltaInKelvin; } public static bool operator !=(TemperatureDelta l, TemperatureDelta r) { return !(l == r); } public static bool operator <(TemperatureDelta l, TemperatureDelta r) { return l._deltaInKelvin < r._deltaInKelvin; } public static bool operator >(TemperatureDelta l, TemperatureDelta r) { return l._deltaInKelvin > r._deltaInKelvin; } public static bool operator <=(TemperatureDelta l, TemperatureDelta r) { return l._deltaInKelvin <= r._deltaInKelvin; } public static bool operator >=(TemperatureDelta l, TemperatureDelta r) { return l._deltaInKelvin >= r._deltaInKelvin; } public Temperature ApplyTo(Temperature temperature) { decimal resultInKelvin = temperature.ToKelvin() + _deltaInKelvin; if (resultInKelvin < 0) { throw new InvalidOperationException("Resulting temperature would be below 0 K."); } return Temperature.FromKelvin(resultInKelvin); } public int CompareTo(TemperatureDelta other) { return _deltaInKelvin.CompareTo(other._deltaInKelvin); } public bool Equals(TemperatureDelta other) { return this == other; } public override bool Equals(object? obj) { return obj is TemperatureDelta d && Equals(d); } public override int GetHashCode() { return _deltaInKelvin.GetHashCode(); } public decimal ToKelvin() { return _deltaInKelvin; } public decimal ToCelsius() { return _deltaInKelvin; } public decimal ToFahrenheit() { decimal celsiusDelta = ToCelsius(); decimal fahrenheitDelta = celsiusDelta / Temperature.FahrenheitToCelsiusRatio; return fahrenheitDelta; } public override string ToString() { return $"{_deltaInKelvin:+0.###;-0.###;0} K"; } public bool IsEqualTo(TemperatureDelta other, decimal tolerance) { return Math.Abs(_deltaInKelvin - other._deltaInKelvin) < tolerance; } public int CompareTo(TemperatureDelta other, decimal tolerance) { decimal diff = _deltaInKelvin - other._deltaInKelvin; if (Math.Abs(diff) < tolerance) { return 0; } return diff < 0 ? -1 : 1; } public static IEqualityComparer<TemperatureDelta> GetComparer(decimal tolerance) { return new TolerantEqualityComparer(tolerance); } private sealed class TolerantEqualityComparer : IEqualityComparer<TemperatureDelta> { private readonly decimal _tolerance; public TolerantEqualityComparer(decimal tolerance) { _tolerance = tolerance; } public bool Equals(TemperatureDelta x, TemperatureDelta y) { return x.IsEqualTo(y, _tolerance); } public int GetHashCode(TemperatureDelta obj) { return Math.Round(obj._deltaInKelvin / _tolerance).GetHashCode(); } } }
NOTE
Dans cet article, j'ai structuré le développement et l'évolution du type Temperature
pour illustrer les problèmes rencontrés et comment les résoudre étape par étape.
Lorsque je développe, j'utilise une méthode de développement dirigée par les tests ( TDD) particulièrement efficace et simple à mettre en œuvre avec des objets de type valeur.
Si vous les utilisez dans vos projets, vous constaterez rapidement que leur utilisation offre des avantages considérables. Ils ont la capacité de saisir une grande partie de la complexité et de la redondance d'un système, contribuant à sa structuration. En conséquence, votre code devient nettement plus simple. Lorsque ces objets sont utilisés conjointement avec des tests unitaires, le nombre de bugs diminue également de façon significative.
Retour au cas d'utilisation
Différentes options
Comme mentionné plus haut dans l'article, l'accès direct à la valeur sous-jacente d'un objet valeur est déconseillé, ce qui explique pourquoi la valeur décimale est cachée et uniquement accessible via des méthodes d'exportation spécifiant les unités. Cette approche présente plusieurs avantages, notamment en termes de sécurité et de maintenabilité du code.
Cependant, pour effectuer des calculs impliquant des types différents comme dans notre cas V = nRT/P
où je rappelle que T est la température, P la pression, R la constante universelle des gaz parfaits en J/(mol·K) et n le nombre de moles. on risque de devoir souvent exposer finalement cette valeur.
Plusieurs options sont envisageables.
Première option
Pour éviter de créer de nouveaux objets valeur, n et R sont de simples décimaux. Dans ce cas, on va casser notre typage par endroit. On essayera de limiter les endroits où cela se passe et seront conscient, à ces endroits particulier du code que le retour aux types primitifs impose plus d'attention.
Seconde option
Allons jusqu'au bout des choses et créons des types qui représentent les concepts "intermédiaire" comme un nombre de mole. Il possède déjà un invariant simple, il ne peut être négatif. Plus simple que la température il ne possède qu'une unité.
Il est également envisageable de définir un type représentant les joules par mole kelvin. Cependant, prudence est de mise ! La création d'un tel type doit être justifiée par une logique métier claire, et il n'est pas question de lui attribuer un nom arbitraire choisi uniquement par nous, les développeurs. Les experts du domaine concerné doivent être consultés pour s'accorder sur un nom approprié pour ce concept. Si l'idée s'avère dénuée de pertinence métier, mieux vaut l'abandonner. En revanche, si elle est jugée utile, alors allez-y.
Utilisation
Reprenons le code de notre calculateur :
public static class GasVolumeCalculator { // Calcul du volume d'un gaz parfait à partir de P, n, et T public static double Calculate(Pressure pressure, Mole moles, Temperature temperature) { ThermalEnergy energy = temperature.GetEnergyFor(moles); Volume volume = energy / pressure; return volume; } }
En adoptant des types fortement typés et en développant un langage spécifique au domaine (DSL), le code gagne en clarté et en expressivité
NOTE
Un Domain-Specific Language (DSL) est une forme de langage informatique conçue pour cibler un type particulier de problème ou pour être utilisée dans un contexte d'application spécifique. Contrairement aux langages de programmation généraux comme C# ou Java, qui sont conçus pour être polyvalents et couvrir un large éventail de problèmes de programmation (dit langages impératifs), un DSL (langage déclaratif) est optimisé pour un ensemble restreint de tâches et vise à simplifier la programmation dans un domaine spécifique.
Dans notre cas, les objets valeur, grâce à leur encapsulation et à leurs méthodes, constituent notre DSL. Ce code n'est pas conçu pour manipuler les températures, les énergies, les pressions, etc., de manière universelle, mais de façon adapté à notre domaine d'activité. Les optimisations et la structure même du modèle pourraient paraître étranges à une autre équipe spécialisée dans un domaine différent traitant également de pression et de volume, mais elles sont parfaitement ajustées aux besoins spécifiques du domaine sur lequel nous nous concentrons.
Conclusion
Les value objects représentent un pilier fondamental dans la capture de la complexité d'une application. Ils permettent de rendre explicite la logique métier, facilitant ainsi la maintenance du code et réduisant les erreurs.
La création de ces objets ne relève pas uniquement du domaine des développeurs, mais nécessite également une implication active des experts métier. Leur expertise est cruciale pour définir et valider le modèle de domaine, que ce soit dans le cadre de DDD, de méthodologies agiles, ou autres. Le degré de suppression des types primitifs dépend donc étroitement de cette collaboration, et il est essentiel de ne pas introduire de concepts artificiels dans le modèle sans validation préalable des experts. Toutefois, il est également pertinent de remettre en question ces mêmes experts, car parfois, certains concepts implicites pour eux peuvent être clarifiés et améliorés dans le processus de digitalisation.
J'espère que cet article vous a fourni des pistes de réflexion utiles. N'hésitez pas à intégrer les value objects dans votre code pour des données variées telles que les montants, les pourcentages, les ratios, les devises, etc. Toutefois, gardez à l'esprit que l'utilisation des value objects ne consiste pas simplement à satisfaire une préférence personnelle, mais à répondre à un besoin métier spécifique.