Fondamentaux • Système de types
Introduction
Dans cet article, nous allons présenter une brique de base proposée par C# : le système de types.
En effet, contrairement à certains langages comme Javascript ou Python, C# est un fortement typé. Nous allons voir ensemble comment fonctionne ce système de types en C#.
Système de types en C#
C# est un langage fortement typé. Lorsque nous déclarons une variable ou une constante, nous devons définir son nom et son type (implicitement ou explicitement).
// → count est un entier, son type est expliciteint count;
// → name est une chaine de caractères, son type est implicitevar name = "Max";
// → cities est une liste de chaines de caractères, son type est explicitevar cities = new List<string>();
// → countries est une liste de chaines de caractères, son type est expliciteList<string> countries = new();Il en va de même lorsque nous déclarons une méthode. Nous devons définir le nom de la méthode et pour chaque paramètre :
- son nom ;
- son type ;
- son modificateur :
in,ref,ref readonlyetout; - optionnellement sa valeur par défaut.
La valeur de retour d’une méthode doit également être typée (void est un type).
Une fois que nous avons défini le type d’une variable, nous ne pouvons pas le changer.
Ainsi une variable de type ìnt ne peut pas recevoir une valeur de type string.
// → day est une chaine de caractères et sa valeur est "Friday"string day = "Friday";
// → erreur de compilation : Cannot convert source type 'string' to target type 'int'day = 3;Contrairement à Typescript, il n’est pas non plus possible de définir plusieurs types pour une variable.
Ainsi une variable ne peut pas avoir le type string ou string[].
Ce système de types peut sembler contraignant, mais il permet de détecter des erreurs au plus tôt, c’est-à-dire à la compilation plutôt qu’à l’exécution.
Type intégré
C# fournit un ensemble de types intégrés (built-in) : bool, char, int, string, decimal, etc.
Ces types sont essentiels pour nous aider à définir le format des données que nous manipulons.
Voici quelques exemples de types intégrés :
- le booléen (faux ou vrai)
bool; - les nombres entiers signés (positifs ou négatifs)
sbyte,short,int,long; - les nombres entiers non signés (uniquement positifs)
byte,ushort,uint,ulong; - les nombres réels (à virgule flottante)
float,double,decimal; - le caractère
char; - la chaine de caractères
string.
Type personnalisé
C# nous permet de définir nos propres types en mettant à notre disposition des mots clefs :
Type valeur
Lorsque nous déclarons une variable de type valeur (value type), elle contient directement la valeur.
var firstname = "Max";var lastname = "Mad";
// → fullname = "Mad Max"var fullname = $"{lastname} {firstname}"; // nous pouvons directement substituer la variable par sa valeurAssigner une variable de type valeur à une autre variable du même type copiera sa valeur. Les variables seront ensuite indépendantes et modifier la valeur d’une variable n’impactera pas l’autre variable.
var firstname = "Max";
// → lastname = "Max"var lastname = firstname;
// → lastname = "Mad"lastname = "Mad";
// → fullname = "Mad Max"var fullname = $"{lastname} {firstname}";Les types suivants sont de type valeur :
- les énumérations
enum; - les structures
struct; - les types intégrés (qui sont en fait des structures en C#).
Les types valeurs sont scellés (sealed) : nous ne pouvons pas en hériter.
En effet, nous ne pouvons pas hériter de : int, string ou DateTime.
Les types valeurs ne peuvent pas hériter d’un autre type de base. Ils peuvent néanmoins implémenter des interfaces.
Type de valeur littéral
Qu’est-ce-qu’une valeur littérale ?
"Max", 5, 3.1415f sont des valeurs littérales.
En C# les valeurs littérales reçoivent un type intégré. Il est alors possible d’utiliser les méthodes et propriétés du type correspondant.
// → stringLength = 1// "5" reçoit le type chaine de caractères et// nous avons accès à sa longueur via la propriété Lengthvar stringLength = "5".Length;
// → stringifyInteger = "19"// 19 reçoit le type entier et// nous pouvons le convertir en chaine de caractères via la méthode ToString()var stringifyInteger = 19.ToString();Type référence
Lorsque nous déclarons une variable de type référence (reference type), elle contient la référence à la valeur. Nous pouvons aussi dire qu’elle pointe vers cette valeur.
La valeur est null par défaut.
null signifie précisément qu’il n’y a pas d’instance.
Si nous créons une instance du type de la variable via l’opérateur new alors la variable contiendra la référence à cette nouvelle instance.
Nous pouvons également affecter une instance existante du même type à la variable ; la variable pointera alors vers cette instance existante.
// arrayOfInteger est une variable de type tableau d'entier// elle référence null car elle a été déclarée sans être instanciéeint[] arrayOfInteger;
// oneTwoThree est une variable de type tableau d'entier// elle référence une nouvelle instance de ce type qui contient 1, 2 et 3var oneTwoThree = new[] { 1, 2, 3 };
// array123 est une variable de type tableau d'entier// elle référence la même instance que oneTwoThreevar array123 = oneTwoThree;
// anotherArray123 est une variable de type tableau d'entier// elle référence la même instance que array123 (et donc celle de oneTwoThree)var anotherArray123 = array123;// → l'instance du tableau d'entier contient maintenant -1, 2, 3oneTwoThree[0] = -1;
// → oneTwoThree: -1, 2, 3Console.WriteLine($"oneTwoThree: {string.Join(',', oneTwoThree)}");
// → array123: -1, 2, 3Console.WriteLine($"array123: {string.Join(',', array123)}");
// → anotherArray123: -1, 2, 3Console.WriteLine($"anotherArray123: {string.Join(',', anotherArray123)}");Les types suivants sont de type référence :
- les classes
class; - les enregistrements
record; - les interfaces
interface; - les délégués
delegate; - les tableaux
array; - les objets
object.
Type d’objet personnalisé
Définir un type d’objet consiste à créer un modèle/patron/plan (blueprint) avec ses membres (propriétés, méthodes, constructeurs, etc.). Nous pouvons ensuite nous servir de ce modèle pour générer des objets qui posséderont les membres du modèle. Ce principe s’appelle l’instanciation et le constructeur est utilisé dans ce cas. Les objets ainsi créés auront leur propre cycle de vie, tout en respectant les règles définies par leur modèle.
Un type d’objet personnalisé peut être déclaré comme :
- structure
struct; - classe
class; - enregistrement
record.
Membres
Les membres permettent de définir le type d’objet.
Voici la liste exhaustive des types de membres qui peuvent être déclarés dans un type d’objet :
- champs (fields) ;
- constantes (constants) ;
- propriétés (properties) ;
- méthodes (methods) ;
- constructeurs (constructors) ;
- finaliseurs (finalizers) ;
- événements (events) ;
- indexeurs (indexers) ;
- opérateurs (operators) ;
- types imbriqués (nested types).
Membres d’instance
Sauf indication contraire, tous les membres sont copiés dans chacune des instances du type d’objet. Chaque instance est ainsi indépendante et la valeur de ses membres lui est propre.
Membres statiques
Un membre statique est partagé entre toutes les instances du type d’objet. Ainsi sa valeur ou sa référence est commune à toutes les instances du type d’objet.
Nous pouvons indiquer qu’un membre est statique via le mot clef static.
Les constantes sont implicitement statiques et ne doivent pas être précédés de static.
Les opérateurs sont forcément statiques et doivent être précédés de static.
Les indexeurs et les finaliseurs ne peuvent pas être statiques.
Même si cela peut sembler contre-intuitif, un constructeur peut être statique.
Tableau des types de membre
| Type de membre | Instance | Statique |
|---|---|---|
| champ | ⨉ | ⨉ |
| constante | ⨉ | |
| propriété | ⨉ | ⨉ |
| méthode | ⨉ | ⨉ |
| constructeur | ⨉ | ⨉ |
| finaliseur | ⨉ | |
| événement | ⨉ | ⨉ |
| indexeur | ⨉ | |
| opérateur | ⨉ | |
| type imbriqué | ⨉ | ⨉ |
Encapsulation
Les types d’objets nous permettent d’isoler et de définir le comportement d’une partie du logiciel que nous développons.
Nous souhaitons parfois cacher la mécanique interne afin de n’exposer que les membres utiles à l’extérieur.
À l’aide des modificateurs de visibilité public, protected, private,
nous pouvons montrer ou cacher une partie des membres du type.
Ce principe s’appelle l’encapsulation.
Exemple d’un type d’objet personnalisé
Voici une classe qui représente une mesure de température :
/// <summary>Représente une mesure de température/// exprimée en degrés Celsius ou en degrés Fahrenheit.</summary>public class TemperatureMeasurement{ private float _measure;
/// <summary>Valeur de la température.</summary> public float Measure { get => _measure; set { _measure = value; MeasureChanged?.Invoke(value); } }
/// <summary>Unité dans laquelle la valeur de la température est exprimée.</summary> public string Unit { get; init; } = CelsiusDegreesUnit;
/// <summary>Unité de température : degrés Celsius.</summary> public const string CelsiusDegreesUnit = "Celsius";
/// <summary>Unité de température : degrés Fahrenheit.</summary> public const string FahrenheitDegreesUnit = "Fahrenheit";
/// <summary>Évenement appelé lorsqu'une nouvelle valeur de température est définie.</summary> public event Action<float>? MeasureChanged;
/// <summary>Instancie un nouvel objet de type <see cref="TemperatureMeasurement" /> /// dont la valeur et l'unité sont spécifiées.</summary> public TemperatureMeasurement(float measure, string unit) { ValidateUnit(unit); _measure = measure; Unit = unit; }
/// <summary>Retourne une nouvelle instance de type <see cref="TemperatureMeasurement" /> /// avec la valeur zéro et l'unité degrés Celsius.</summary> public static TemperatureMeasurement ZeroDegreesCelsius => new(0.0f, CelsiusDegreesUnit);
/// <summary>Retourne une nouvelle instance de type <see cref="TemperatureMeasurement" /> /// avec la valeur zéro et l'unité degrés Fahrenheit.</summary> public static TemperatureMeasurement ZeroDegreesFahrenheit => new(0.0f, FahrenheitDegreesUnit);
/// <summary>Retourne une nouvelle instance de type <see cref="TemperatureMeasurement" /> /// exprimée en degrés Celsius avec la valeur spécifiée.</summary> public static TemperatureMeasurement InDegreesCelsius(float measure) => new(measure, CelsiusDegreesUnit);
/// <summary>Retourne une nouvelle instance de type <see cref="TemperatureMeasurement" /> /// exprimée en degrés Fahrenheit avec la valeur spécifiée.</summary> public static TemperatureMeasurement InDegreesFahrenheit(float measure) => new(measure, FahrenheitDegreesUnit);
/// <summary>Additionne les deux mesures de température.</summary> public static TemperatureMeasurement operator +(TemperatureMeasurement left, TemperatureMeasurement right) { ValidateUnit(left.Unit); ValidateUnit(right.Unit);
if (left.Unit != right.Unit) { throw new ArgumentException("Units must be the same"); }
return new TemperatureMeasurement(left.Measure + right.Measure, left.Unit); }
/// <summary>Soustrait la seconde mesure de la premìère.</summary> public static TemperatureMeasurement operator -(TemperatureMeasurement left, TemperatureMeasurement right) { ValidateUnit(left.Unit); ValidateUnit(right.Unit);
if (left.Unit != right.Unit) { throw new ArgumentException("Units must be the same"); }
return new TemperatureMeasurement(left.Measure - right.Measure, left.Unit); }
private static readonly string[] AllowedUnits = [CelsiusDegreesUnit, FahrenheitDegreesUnit];
private static void ValidateUnit(string unit) { if (!AllowedUnits.Contains(unit)) { throw new ArgumentOutOfRangeException( nameof(unit), $"Allowed units are: {string.Join(", ", Units)}"); } }}Champ _measure & propriété Measure
Le champ _measure de type float contient la valeur de la mesure de température.
Il est associé à la propriété Measure qui retourne sa valeur via get
et qui modifie sa valeur via set.
Nous voyons avec ce bout de code comment fonctionne explicitement une propriété.
Événement MeasureChanged
Décomposer explicitement la propriété Measure nous permet de profiter du setter
pour déclencher l’événement MeasureChanged.
À chaque modification de la mesure, les abonnés à l’événement (s’il y en a)
seront notifiés avec sa nouvelle valeur.
Propriété Unit
La propriété Unit de type string a une valeur par défaut définie.
Son setter est de type init, ce qui signifie que sa valeur ne pourra être modifiée
que lors de l’instanciation de TemperatureMeasurement.
Une fois l’instance créée, Unit ne peut plus être modifiée et devient donc immuable.
Constantes CelsiusDegreesUnit & FahrenheitDegreesUnit
Deux constantes publiques de type string CelsiusDegreesUnit et FahrenheitDegreesUnit sont déclarées.
Leurs valeurs représentent les deux valeurs d’unité autorisées pour Unit.
Constructeur
TemperatureMeasurement possède un constructeur avec deux paramètres measure et unit.
Le constructeur appelle la méthode privée statique ValidateUnit(string unit)
en charge de vérifier que l’unité correspond aux valeurs autorisées.
La valeur du paramètre measure est ensuite copiée dans le champ _measure
(sans passer par le setter de la propriété Measure)
car nous ne souhaitons pas déclencher l’événement MeasureChanged lors de l’instanciation.
La valeur du paramètre unit est copiée dans la propriété Unit.
Méthode ValidateUnit(string unit)
La méthode ValidateUnit(string unit) est privée, car le monde extérieur n’a pas besoin de savoir qu’elle existe.
Elle est statique parce qu’elle n’accède à aucun membre de l’instance : l’unité à vérifier est passée en paramètre. Il s’agit d’une optimisation évitant que le code de la méthode soit dupliqué inutilement dans chaque instance.
Si l’unité n’est pas valide alors une exception ArgumentOutOfRangeException est levée.
Tableau AllowedUnits
La validité de l’unité est vérifiée par la présence ou l’absence de l’unité sans le tableau AllowedUnits.
Ce tableau est statique et en lecture seule.
En effet, ses éléments sont définis une bonne fois pour toutes et sont valables pour toutes les instances.
Nous pouvons considérer que static readonly équivaut à const d’un type intégré.
Méthodes ZeroDegreesCelsius() & ZeroDegreesFahrenheit()
Les deux méthodes statiques ZeroDegreesCelsius() et ZeroDegreesFahrenheit()
retournent une nouvelle instance de type TemperatureMeasurement
avec la valeur et l’unité de mesure déjà fixée.
Ce sont des méthodes helper qui nous facilitent l’écriture du code.
Méthodes InDegreesCelsius(float measure) & InDegreesFahrenheit(float measure)
Il en est de même pour les méthodes statiques InDegreesCelsius(float measure) et InDegreesFahrenheit(float measure).
Ces méthodes helper nous permettent d’instancier un objet de type TemperatureMeasurement avec l’unité déjà fournie.
Cela simplifie le code et améliore la lisibilité.
Opérateur + & -
Les opérateurs + et - sont définis.
Ils nous permettent d’additionner et de soustraire deux mesures de température.
Comme pour le constructeur, la validité de l’unité est contrôlée pour chaque mesure.
De plus, chaque mesure doit avoir la même unité afin de ne pas mélanger des choux et des carottes.
Utilisation du type d’objet personnalisé
Voyons maintenant comment utiliser notre type TemperatureMeasurement.
Instanciation
// → Instanciation via le constructeurvar bodyTemperatureInDegreesCelsius = new TemperatureMeasurement(37.0f, TemperatureMeasurement.CelsiusDegreesUnit);
// → Instanciation via le constructeurvar bodyTemperatureInDegreesFahrenheit = new TemperatureMeasurement(98.6f, TemperatureMeasurement.FahrenheitDegreesUnit);
// → Instanciation via la méthode helpervar zeroDegreesCelsius = TemperatureMeasurement.ZeroDegreesCelsius;
// → Instanciation via la méthode helpervar twentyDegreesFahrenheit = TemperatureMeasurement.InDegreesFahrenheit(20.0f);Les membres statiques sont accessibles via Nom Type . Nom Membre Statique :
TemperatureMeasurement.CelsiusDegreesUnit;TemperatureMeasurement.ZeroDegreesCelsius;- etc.
Opérateurs
var oneDegreesCelsius = TemperatureMeasurement.InDegreesCelsius(1.0f);var twoDegreesCelsius = TemperatureMeasurement.InDegreesCelsius(2.0f);
// → threeDegreesCelsius.Measure = 3.0fvar threeDegreesCelsius = oneDegreesCelsius + twoDegreesCelsius;
// → negativeOneDegreesCelsius.Measure = -1.0fvar negativeOneDegreesCelsius = oneDegreesCelsius - threeDegreesCelsius;Les opérateurs + et - définis nous permettent d’additionner et de soustraire simplement
deux mesures de température.
En savoir plus
Nous avons maintenant une bonne vision d’ensemble du système de types proposé par C#.
Les types intégrés nous permettent de définir les données que nous manipulons.
.NET propose également un très grand nombre de type d’objets prêts à l’emploi :
- les collections
System.Collections: énumérables, listes, dictionnaires, piles, etc. - les requêtes de collection
System.Linq: méthodes d’extension telle queSelect(),Where(),OrderBy(), etc. - les entrées-sorties
System.IO: lecteurs, dossiers, fichiers, flux, etc. - les textes
System.Text: encodeurs, décodeurs, convertisseurs, etc.
Les types d’objet personnalisés nous permettent de modéliser des comportements riches et ainsi de profiter de toute la puissance de la programmation orientée objet.
Si vous souhaitez en savoir plus sur la programmation orientée objet, Microsoft propose de nombreux articles, en voici quelques-uns :