Aller au contenu

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 explicite
int count;
// → name est une chaine de caractères, son type est implicite
var name = "Max";
// → cities est une liste de chaines de caractères, son type est explicite
var cities = new List<string>();
// → countries est une liste de chaines de caractères, son type est explicite
List<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 readonly et out ;
  • 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 :

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 valeur

Assigner 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é Length
var 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ée
int[] 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 3
var oneTwoThree = new[] { 1, 2, 3 };
// array123 est une variable de type tableau d'entier
// elle référence la même instance que oneTwoThree
var 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, 3
oneTwoThree[0] = -1;
// → oneTwoThree: -1, 2, 3
Console.WriteLine($"oneTwoThree: {string.Join(',', oneTwoThree)}");
// → array123: -1, 2, 3
Console.WriteLine($"array123: {string.Join(',', array123)}");
// → anotherArray123: -1, 2, 3
Console.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 :

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 membreInstanceStatique
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 constructeur
var bodyTemperatureInDegreesCelsius
= new TemperatureMeasurement(37.0f, TemperatureMeasurement.CelsiusDegreesUnit);
// → Instanciation via le constructeur
var bodyTemperatureInDegreesFahrenheit
= new TemperatureMeasurement(98.6f, TemperatureMeasurement.FahrenheitDegreesUnit);
// → Instanciation via la méthode helper
var zeroDegreesCelsius = TemperatureMeasurement.ZeroDegreesCelsius;
// → Instanciation via la méthode helper
var 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.0f
var threeDegreesCelsius = oneDegreesCelsius + twoDegreesCelsius;
// → negativeOneDegreesCelsius.Measure = -1.0f
var 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 que Select(), 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 :