Skip to content

3. Les bases du langage C#

3.1. Introduction

Nous traitons C# d'abord comme un langage de programmation classique. Nous aborderons les classes dans le chapitre suivant. Dans un programme, on trouve deux choses :

  • des données ;
  • les instructions qui les manipulent.

On s'efforce généralement de séparer les données des instructions :

Image

Tous les exemples de ce chapitre sont des applications fichier unique : un seul fichier [.cs], sans fichier projet [.csproj], que l'on exécute avec la commande [dotnet run bases-01.cs]. Cette possibilité, apparue avec .NET 10, a été présentée au chapitre précédent. Les exemples se trouvent dans le dossier [03-bases] du code du cours. Ils s'exécutent en culture française (fr-FR), comme sur un poste Windows français : les nombres réels sont affichés avec une virgule décimale.

3.2. Le programme minimal

3.2.1. Les instructions de haut niveau

Le programme [bases-01.cs] est le suivant :

1
2
3
4
5
// programme minimal écrit avec des instructions de haut niveau (C# 9)
// les instructions sont écrites directement dans le fichier : ni classe, ni méthode Main
Console.WriteLine("Bonjour le monde !");
// le nom complet de la classe Console est System.Console
System.Console.WriteLine("Nombre d'arguments reçus : " + args.Length);

Résultats :

1
2
3
> dotnet run bases-01.cs
Bonjour le monde !
Nombre d'arguments reçus : 0

Commentaires :

  • le programme ne contient ni classe ni méthode : les instructions sont écrites directement dans le fichier. On appelle cela des instructions de haut niveau (C# 9, 2020). Un seul fichier d'un programme peut en contenir ;
  • ligne 3 : la méthode [WriteLine] de la classe [Console] écrit une ligne à l'écran ;
  • ligne 5 : la classe [Console] appartient à l'espace de noms [System]. Son nom complet est [System.Console]. Un espace de noms regroupe des types apparentés, comme un dossier regroupe des fichiers ;
  • ligne 5 : le programme dispose d'un tableau [args] contenant les arguments de la ligne de commande. Nous y reviendrons dans la section « Arguments du programme principal ». Le nombre d'éléments du tableau est [args.Length]. L'opérateur + concatène ici une chaîne et un nombre : le nombre est d'abord converti en chaîne.

Avec deux arguments sur la ligne de commande (tout ce qui suit [--] est passé au programme) :

1
2
3
> dotnet run bases-01.cs -- a0 a1
Bonjour le monde !
Nombre d'arguments reçus : 2

3.2.2. La forme classique avec une méthode Main

Le cours de 2008 (C# 3.0) ne connaissait pas les instructions de haut niveau. Le même programme s'écrivait de la façon suivante [bases-02.cs] :

// la forme classique du même programme (la seule possible jusqu'à C# 8)
using System;

namespace Bases
{
    class Program
    {
        // la méthode Main est le point d'entrée du programme
        static void Main(string[] args)
        {
            Console.WriteLine("Bonjour le monde !");
            System.Console.WriteLine("Nombre d'arguments reçus : " + args.Length);
        }
    }
}

Résultats :

1
2
3
> dotnet run bases-02.cs
Bonjour le monde !
Nombre d'arguments reçus : 0

Commentaires :

  • ligne 2 : la directive [using] importe l'espace de noms [System]. On peut alors écrire [Console] au lieu de [System.Console] ;
  • ligne 4 : la classe est placée dans l'espace de noms [Bases]. Depuis C# 10, on peut écrire [namespace Bases;] (espace de noms de portée fichier) : tout le fichier appartient alors à cet espace de noms, sans accolades ni niveau d'indentation supplémentaire. C'est la forme que nous utiliserons désormais ;
  • ligne 6 : en C#, tout code appartient à une classe. La classe s'appelle ici [Program] ;
  • ligne 9 : la méthode statique [Main] est le point d'entrée du programme : c'est elle que le système exécute au lancement. Son paramètre [args] reçoit les arguments de la ligne de commande.

Les deux programmes sont équivalents. Lorsqu'il rencontre des instructions de haut niveau, le compilateur génère lui-même une classe et une méthode [Main] qui contiennent ces instructions, ainsi que le paramètre [string[] args]. Les instructions de haut niveau ne sont donc qu'une écriture abrégée de la forme classique. Elles conviennent bien aux petits programmes de ce chapitre ; la forme classique reste utile lorsqu'on veut contrôler la classe qui contient [Main].

3.2.3. Les using implicites

Le programme [bases-01.cs] utilise la classe [Console] sans directive [using System;]. C'est possible parce que le SDK .NET ajoute automatiquement à tout programme console un fichier généré contenant des directives [global using] (C# 10), valables pour tous les fichiers du programme. Pour [bases-01.cs], ce fichier contient :

1
2
3
4
5
6
7
8
// <auto-generated/>
global using System;
global using System.Collections.Generic;
global using System.IO;
global using System.Linq;
global using System.Net.Http;
global using System.Threading;
global using System.Threading.Tasks;

Ce sont les using implicites : les espaces de noms les plus utilisés sont importés d'office. Un espace de noms absent de cette liste, comme [System.Globalization], doit toujours être importé par une directive [using] en tête de fichier. Dans le programme [bases-02.cs], la directive [using System;] est donc redondante : nous l'avons laissée pour montrer la forme de 2008.

3.3. Les données de C#

C# utilise les types de données suivants :

  • les nombres entiers ;
  • les nombres réels ;
  • les nombres décimaux ;
  • les caractères et chaînes de caractères ;
  • les booléens ;
  • les objets.

3.3.1. Les types de données prédéfinis

Type C#
Type .NET
Donnée représentée
Suffixe des littéraux
Codage
Domaine de valeurs
[char]
[Char] (S)
caractère
 
2 octets
caractère Unicode (UTF-16)
[string]
[String] (C)
chaîne de caractères
  
référence sur une séquence de caractères Unicode
[int]
[Int32] (S)
nombre entier
 
4 octets
[-2³¹, 2³¹-1] soit [-2147483648, 2147483647]
[uint]
[UInt32] (S)
entier non signé
U
4 octets
[0, 2³²-1] soit [0, 4294967295]
[long]
[Int64] (S)
nombre entier
L
8 octets
[-2⁶³, 2⁶³-1] soit [-9223372036854775808, 9223372036854775807]
[ulong]
[UInt64] (S)
entier non signé
UL
8 octets
[0, 2⁶⁴-1] soit [0, 18446744073709551615]
[sbyte]
[SByte] (S)
nombre entier
 
1 octet
[-2⁷, 2⁷-1] soit [-128, 127]
[byte]
[Byte] (S)
entier non signé
 
1 octet
[0, 2⁸-1] soit [0, 255]
[short]
[Int16] (S)
nombre entier
 
2 octets
[-2¹⁵, 2¹⁵-1] soit [-32768, 32767]
[ushort]
[UInt16] (S)
entier non signé
 
2 octets
[0, 2¹⁶-1] soit [0, 65535]
[float]
[Single] (S)
nombre réel
F
4 octets
[1,5 10⁻⁴⁵, 3,4 10³⁸] en valeur absolue, 7 chiffres significatifs
[double]
[Double] (S)
nombre réel
D
8 octets
[5,0 10⁻³²⁴, 1,7 10³⁰⁸] en valeur absolue, 15 à 16 chiffres significatifs
[decimal]
[Decimal] (S)
nombre décimal
M
16 octets
[1,0 10⁻²⁸, 7,9 10²⁸] en valeur absolue, 28 à 29 chiffres significatifs
[bool]
[Boolean] (S)
booléen
 
1 octet
[true], [false]
[object]
[Object] (C)
référence d'objet
  
référence sur un objet de n'importe quel type

Ci-dessus, on a mis en face des types C# leur type .NET équivalent, avec la mention (S) si ce type est une structure et (C) si c'est une classe. On découvre qu'il y a deux noms possibles pour un entier sur 32 bits : [int] et [Int32]. Le type [int] est un mot clé du langage C#. [Int32] est une structure appartenant à l'espace de noms [System] : son nom complet est [System.Int32]. Le type [int] est un alias C# qui désigne la structure .NET [System.Int32]. De même, le type C# [string] est un alias du type .NET [System.String]. [System.String] est une classe et non une structure. Les deux notions sont proches, avec cependant la différence fondamentale suivante :

  • une variable de type structure contient directement sa valeur : on parle de type valeur ;
  • une variable de type classe contient l'adresse de l'objet (sa référence) : on parle de type référence.

S'y ajoutent deux types moins courants :

  • [nint] et [nuint] (C# 9) sont des entiers signé et non signé dont la taille est celle d'une adresse mémoire : 4 octets sur un système 32 bits, 8 octets sur un système 64 bits. Ils servent surtout à dialoguer avec du code natif ;
  • [System.Half] (.NET 5) est un réel codé sur 2 octets, de faible précision, utilisé en calcul graphique et en apprentissage automatique. Ce n'est pas un type prédéfini du langage : il n'a pas de mot clé C#.

Une structure, comme une classe, est un type complexe ayant des attributs et des méthodes. Ainsi, on peut écrire :

string? nomDuType = 3.GetType().FullName;

Le littéral 3 est par défaut de type C# [int], donc de type .NET [System.Int32]. Cette structure a une méthode [GetType()] qui rend un objet décrivant le type de la donnée. Parmi les propriétés de cet objet, [FullName] est le nom complet du type. Le littéral 3 est donc un objet plus complexe qu'il n'y paraît à première vue. Le point d'interrogation de [string?] sera expliqué un peu plus loin.

Le programme [bases-03.cs] illustre ces différents points :

// les types prédéfinis : type .NET et taille en octets
int ent = 2;
float fl = 10.5F;
double d = -4.6;
string s = "essai";
uint ui = 5;
long l = 1000;
ulong ul = 1001;
byte octet = 5;
sbyte so = -5;
short sh = -4;
ushort ush = 10;
decimal dec = 10.67M;
bool b = true;
char c = 'A';
// {0}, {1}, {2} sont remplacés par les paramètres n° 0, 1, 2 qui suivent le format
Console.WriteLine("Type de ent[{1}] : [{0},{2}]", ent.GetType().FullName, ent, sizeof(int));
Console.WriteLine("Type de fl[{1}] : [{0},{2}]", fl.GetType().FullName, fl, sizeof(float));
Console.WriteLine("Type de d[{1}] : [{0},{2}]", d.GetType().FullName, d, sizeof(double));
Console.WriteLine("Type de s[{1}] : [{0}]", s.GetType().FullName, s);
Console.WriteLine("Type de ui[{1}] : [{0},{2}]", ui.GetType().FullName, ui, sizeof(uint));
Console.WriteLine("Type de l[{1}] : [{0},{2}]", l.GetType().FullName, l, sizeof(long));
Console.WriteLine("Type de ul[{1}] : [{0},{2}]", ul.GetType().FullName, ul, sizeof(ulong));
Console.WriteLine("Type de octet[{1}] : [{0},{2}]", octet.GetType().FullName, octet, sizeof(byte));
Console.WriteLine("Type de so[{1}] : [{0},{2}]", so.GetType().FullName, so, sizeof(sbyte));
Console.WriteLine("Type de sh[{1}] : [{0},{2}]", sh.GetType().FullName, sh, sizeof(short));
Console.WriteLine("Type de ush[{1}] : [{0},{2}]", ush.GetType().FullName, ush, sizeof(ushort));
Console.WriteLine("Type de dec[{1}] : [{0},{2}]", dec.GetType().FullName, dec, sizeof(decimal));
Console.WriteLine("Type de b[{1}] : [{0},{2}]", b.GetType().FullName, b, sizeof(bool));
Console.WriteLine("Type de c[{1}] : [{0},{2}]", c.GetType().FullName, c, sizeof(char));
// entier dont la taille est celle d'une adresse mémoire (4 ou 8 octets selon la machine)
nint ni = 7;
Console.WriteLine("Type de ni[{1}] : [{0},{2}]", ni.GetType().FullName, ni, nint.Size);
// réel sur 2 octets (.NET 5)
Half h = (Half)1.5;
Console.WriteLine("Type de h[{1}] : [{0}]", h.GetType().FullName, h);
// le littéral 3 est lui aussi un objet
Console.WriteLine("Type du littéral 3 : " + 3.GetType().FullName);
// valeurs extrêmes
Console.WriteLine("int : [{0}, {1}]", int.MinValue, int.MaxValue);
Console.WriteLine("long : [{0}, {1}]", long.MinValue, long.MaxValue);
Console.WriteLine("decimal : [{0}, {1}]", decimal.MinValue, decimal.MaxValue);

Résultats :

> dotnet run bases-03.cs
Type de ent[2] : [System.Int32,4]
Type de fl[10,5] : [System.Single,4]
Type de d[-4,6] : [System.Double,8]
Type de s[essai] : [System.String]
Type de ui[5] : [System.UInt32,4]
Type de l[1000] : [System.Int64,8]
Type de ul[1001] : [System.UInt64,8]
Type de octet[5] : [System.Byte,1]
Type de so[-5] : [System.SByte,1]
Type de sh[-4] : [System.Int16,2]
Type de ush[10] : [System.UInt16,2]
Type de dec[10,67] : [System.Decimal,16]
Type de b[True] : [System.Boolean,1]
Type de c[A] : [System.Char,2]
Type de ni[7] : [System.IntPtr,8]
Type de h[1,5] : [System.Half]
Type du littéral 3 : System.Int32
int : [-2147483648, 2147483647]
long : [-9223372036854775808, 9223372036854775807]
decimal : [-79228162514264337593543950335, 79228162514264337593543950335]

Commentaires :

  • ligne 2 : déclaration d'un entier [ent] initialisé à 2 ;
  • ligne 16 : l'instruction [Console.WriteLine("... {0} ... {1} ...", param0, param1, ...)] écrit à l'écran le texte qui est son premier paramètre (le format), en remplaçant chaque notation {i} par la valeur du paramètre n° i qui suit le format. Les paramètres sont numérotés à partir de 0 ;
  • ligne 17 : le type d'une variable [v] est obtenu par [v.GetType().FullName]. La taille en octets d'un type valeur prédéfini [T] est obtenue par [sizeof(T)] ;
  • ligne 20 : le type [string] désignant une classe et non une structure, on ne peut pas utiliser l'opérateur [sizeof] ;
  • ligne 33 : [sizeof(nint)] n'est pas autorisé dans du code ordinaire car la taille de [nint] n'est connue qu'à l'exécution. La propriété [nint.Size] la donne : 8 octets sur la machine 64 bits utilisée ;
  • ligne 35 : on ne peut pas écrire [Half h = 1.5;] : il n'existe pas de conversion implicite d'un [double] vers un [Half]. La notation [(Half)] est une conversion explicite (en anglais, un « cast »), que nous étudierons plus loin ;
  • lignes 40-42 : les propriétés [MinValue] et [MaxValue] des types numériques donnent les bornes de leur domaine de valeurs.

L'affichage produit les noms des types .NET et non les alias C#. On remarquera la virgule décimale des réels : les nombres sont convertis en chaînes selon la culture du système, ici le français.

3.3.2. Notation des données littérales

Donnée
Exemples de littéraux
entier [int] (32 bits)
145, -7, 0xFF (hexadécimal), 0b1010_1010 (binaire)
entier [long] (64 bits), suffixe L
100000L, 1_000_000_000_000L
entier non signé, suffixe U ou UL
4000000000U, 10UL
réel [double]
134.789, -45E-18 (-45 10⁻¹⁸)
réel [float], suffixe F
134.789F, -45E-18F
réel [decimal], suffixe M
100000.50M
caractère [char]
'A', 'b', '\n', '\u00e9'
chaîne [string]
"aujourd'hui", "c:\\chap1", @"c:\chap1", $"{nom} a {age} ans", """texte brut"""
booléen [bool]
[true], [false]
date
[new DateTime(1954, 10, 13)] (année, mois, jour) pour le 13/10/1954

Dans le code source, les réels s'écrivent toujours avec un point décimal, quelle que soit la culture du système. Depuis C# 7, on peut écrire les entiers en binaire (préfixe [0b]) et séparer les groupes de chiffres par le caractère _ pour améliorer la lisibilité : [1_000_000] est l'entier un million.

Une chaîne littérale classique peut contenir des séquences d'échappement introduites par le caractère \ : "\n" représente la marque de fin de ligne et non la succession des deux caractères \ et n, "\t" une tabulation, "\"" un guillemet, "\\" le caractère \ lui-même. Pour écrire le chemin Windows c:\chap1\paragraph3, il faut donc écrire "c:\\chap1\\paragraph3". On peut aussi écrire @"c:\chap1\paragraph3" : la syntaxe @"texte" demande que le texte soit pris exactement comme il est écrit, sans interpréter le caractère \. On appelle cela une chaîne verbatim.

C# offre aujourd'hui deux autres formes de chaînes :

  • la chaîne interpolée (C# 6) $"..." : les expressions placées entre accolades sont évaluées et leur valeur est insérée dans la chaîne. Une expression peut être suivie d'un format après le caractère deux-points : [$"{d1:F2}"] affiche le réel [d1] avec deux décimales. C'est la façon la plus lisible de construire une chaîne et nous l'utiliserons désormais à la place des formats {0}, {1}, ... ;
  • la chaîne brute (C# 11) """...""" : elle commence et se termine par au moins trois guillemets. Elle peut contenir des guillemets et des \ sans échappement, et s'étendre sur plusieurs lignes. Les espaces qui précèdent les guillemets fermants sont retirés de chaque ligne, ce qui permet d'indenter le texte avec le code. Elle est très pratique pour écrire du JSON, du SQL ou du HTML.

Le programme [bases-04.cs] montre ces différentes notations :

// la notation des données littérales
// entiers : décimal, hexadécimal (0x), binaire (0b), séparateur _
int i1 = 145, i2 = -7, i3 = 0xFF, i4 = 0b1010_1010;
long l1 = 100000L, l2 = 1_000_000_000_000L;
Console.WriteLine("entiers : {0} {1} {2} {3} {4} {5}", i1, i2, i3, i4, l1, l2);
// réels
double d1 = 134.789, d2 = -45E-18;
float f1 = 134.789F;
decimal m1 = 100000.50M;
Console.WriteLine("réels : {0} {1} {2} {3}", d1, d2, f1, m1);
// caractères et booléens
char c1 = 'A', c2 = '\u00e9';
bool b1 = true;
Console.WriteLine("caractères : {0} {1}, booléen : {2}", c1, c2, b1);
// une date : (année, mois, jour)
DateTime naissance = new DateTime(1954, 10, 13);
Console.WriteLine("date : {0}", naissance.ToShortDateString());

// chaînes classiques : le caractère \ introduit une séquence d'échappement
string chemin1 = "c:\\chap1\\paragraph3";
// chaîne verbatim : le texte est pris tel quel
string chemin2 = @"c:\chap1\paragraph3";
Console.WriteLine(chemin1);
Console.WriteLine(chemin2);
Console.WriteLine("ligne 1\nligne 2\t[tabulation]");

// chaîne interpolée : les expressions entre accolades sont évaluées
string nom = "dupont";
int age = 23;
Console.WriteLine($"{nom} a {age} ans, dans 10 ans il aura {age + 10} ans");
// avec un format après le caractère :
Console.WriteLine($"d1={d1:F1}, i3 en hexadécimal={i3:X}, i4 en binaire={i4:B8}, l2={l2:N0}");
Console.WriteLine($"date={naissance:dddd d MMMM yyyy}");

// chaîne brute (C# 11) : ni échappement, ni doublement des guillemets
string json = """
    {
      "nom": "dupont",
      "chemin": "c:\chap1"
    }
    """;
Console.WriteLine(json);
// chaîne brute interpolée : avec $$, les expressions sont entre {{ et }}
string json2 = $$"""{ "nom": "{{nom}}", "age": {{age}} }""";
Console.WriteLine(json2);

Résultats :

> dotnet run bases-04.cs
entiers : 145 -7 255 170 100000 1000000000000
réels : 134,789 -4,5E-17 134,789 100000,50
caractères : A é, booléen : True
date : 13/10/1954
c:\chap1\paragraph3
c:\chap1\paragraph3
ligne 1
ligne 2    [tabulation]
dupont a 23 ans, dans 10 ans il aura 33 ans
d1=134,8, i3 en hexadécimal=FF, i4 en binaire=10101010, l2=1 000 000 000 000
date=mercredi 13 octobre 1954
{
  "nom": "dupont",
  "chemin": "c:\chap1"
}
{ "nom": "dupont", "age": 23 }

Commentaires :

  • ligne 3 : [0xFF] vaut 255 et [0b1010_1010] vaut 170 ;
  • ligne 7 : -45E-18 est affiché -4,5E-17 : c'est la même valeur, normalisée ;
  • ligne 12 : la séquence \u00e9 désigne le caractère Unicode de code hexadécimal 00E9, c'est-à-dire é ;
  • ligne 16 : une date. La méthode [ToShortDateString] l'affiche au format court de la culture française. Pour une date sans heure, .NET 6 a ajouté le type [DateOnly] ; nous le retrouverons au chapitre 5 ;
  • lignes 20-22 : les deux chaînes sont identiques, comme le montre l'affichage ;
  • ligne 30 : une chaîne interpolée. Les expressions [nom], [age] et [age + 10] sont évaluées puis insérées dans la chaîne ;
  • ligne 32 : quelques formats : F1 (une décimale), X (hexadécimal), B8 (binaire sur 8 chiffres, .NET 8), N0 (séparateur des milliers, aucune décimale) ;
  • ligne 33 : un format de date personnalisé : jour de la semaine, jour, mois en lettres, année ;
  • lignes 36-41 : une chaîne brute sur plusieurs lignes. Les guillemets et le caractère \ y sont écrits tels quels. Les quatre espaces qui précèdent les guillemets fermants ont été retirés de chaque ligne ;
  • ligne 44 : une chaîne brute interpolée. Avec deux caractères $, les expressions s'écrivent entre doubles accolades {{ et }} : les accolades simples, nombreuses en JSON, restent alors du texte ordinaire.

3.3.3. Déclaration des données

3.3.3.1. Rôle des déclarations

Un programme manipule des données caractérisées par un nom et un type. Ces données sont stockées en mémoire. Au moment de la traduction du programme, le compilateur affecte à chaque donnée un emplacement en mémoire caractérisé par une adresse et une taille. Il le fait en s'aidant des déclarations faites par le programmeur. Par ailleurs, celles-ci permettent au compilateur de détecter des erreurs de programmation. Ainsi l'opération [x = x * 2;] sera déclarée erronée si [x] est une chaîne de caractères.

3.3.3.2. Déclaration des constantes

La syntaxe de déclaration d'une constante est la suivante :

const type nom = valeur;

Par exemple :

const double TauxTva = 0.2;

Pourquoi déclarer des constantes ?

  • la lecture du programme est plus aisée si l'on a donné à la constante un nom significatif ;
  • la modification du programme est plus aisée si la « constante » vient à changer. Ainsi, si le taux de TVA passe à 0,25, la seule modification à faire sera celle de l'instruction qui définit sa valeur : [const double TauxTva = 0.25;]. Si l'on avait utilisé 0.2 explicitement dans le programme, ce serait de nombreuses instructions qu'il faudrait modifier.

La valeur d'une constante doit être connue à la compilation : un nombre, une chaîne, un booléen ou une expression calculable à partir d'autres constantes. Par convention, en .NET, les noms des constantes commencent par une majuscule.

3.3.3.3. Déclaration des variables

Une variable est identifiée par un nom et se rapporte à un type de données. C# fait la différence entre majuscules et minuscules : les variables [FIN] et [fin] sont différentes.

Les variables peuvent être initialisées lors de leur déclaration. La syntaxe de déclaration d'une ou plusieurs variables est :

type variable1 = valeur1, variable2 = valeur2, ...;

où [type] est un type prédéfini ou un type défini par le programmeur. L'initialisation est facultative ; en revanche, C# refuse qu'on utilise la valeur d'une variable locale qui n'a pas encore reçu de valeur.

On peut également ne pas préciser le type d'une variable en utilisant le mot clé [var] à la place du type :

var variable1 = valeur1;

Le mot clé [var] ne veut pas dire que la variable n'a pas de type précis : elle a le type de la valeur qui lui est affectée, déterminé par le compilateur. L'initialisation est ici obligatoire, afin que le compilateur puisse en déduire le type. On utilise [var] lorsque le type est évident à la lecture de la valeur.

Le programme [bases-05.cs] est le suivant :

// constantes et variables
const double TauxTva = 0.2;
const string Monnaie = "euros";
double prixHt = 100;
Console.WriteLine($"Prix TTC : {prixHt * (1 + TauxTva)} {Monnaie}");

// une donnée typée explicitement
int i = 2;
Console.WriteLine($"Type de int i=2 : {i.GetType().Name},{i.GetType().FullName}");
// des données typées implicitement avec var
var j = 3;
Console.WriteLine($"Type de var j=3 : {j.GetType().Name},{j.GetType().FullName}");
var aujourdhui = DateTime.Now;
Console.WriteLine($"Type de var aujourdhui : {aujourdhui.GetType().Name},{aujourdhui.GetType().FullName}");
var x = 3.5;
var y = 3.5F;
var z = 10L;
var nom = "dupont";
Console.WriteLine($"x : {x.GetType().Name}, y : {y.GetType().Name}, z : {z.GetType().Name}, nom : {nom.GetType().Name}");
// plusieurs variables déclarées sur une même ligne
int a = 1, b, c = 3;
b = a + c;
Console.WriteLine($"a={a}, b={b}, c={c}");

Résultats :

1
2
3
4
5
6
7
> dotnet run bases-05.cs
Prix TTC : 120 euros
Type de int i=2 : Int32,System.Int32
Type de var j=3 : Int32,System.Int32
Type de var aujourdhui : DateTime,System.DateTime
x : Double, y : Single, z : Int64, nom : String
a=1, b=4, c=3

Commentaires :

  • lignes 2-3 : deux constantes ;
  • ligne 8 : une donnée typée explicitement ;
  • ligne 9 : [GetType().Name] est le nom court du type, [GetType().FullName] son nom complet ;
  • ligne 11 : une donnée typée implicitement. Parce que 3 est de type [int], [j] sera de type [int] ;
  • ligne 13 : parce que [DateTime.Now] (la date et l'heure courantes) est de type [DateTime], [aujourdhui] sera de type [DateTime] ;
  • lignes 15-18 : le type déduit dépend de l'écriture du littéral : 3.5 est un [double], 3.5F un [float], 10L un [long] ;
  • ligne 21 : trois variables déclarées sur une même ligne ; seules [a] et [c] sont initialisées. [b] reçoit sa valeur à la ligne suivante.

Une variable typée implicitement par le mot clé [var] ne peut pas changer de type ensuite. Ainsi, après la ligne 13, on ne pourrait pas écrire :

aujourdhui = "aujourd'hui";

car [aujourdhui] est de type [DateTime]. Nous verrons au chapitre 5, avec LINQ, qu'il est possible de créer un type « à la volée » dans une expression : c'est un type anonyme, auquel le programmeur n'a pas donné de nom. Si une donnée de type anonyme doit être affectée à une variable, la seule façon de déclarer celle-ci est d'utiliser le mot clé [var].

3.3.4. Les types nullables

Une variable d'un type valeur, comme [int], contient toujours une valeur. Il est parfois utile de pouvoir exprimer l'absence de valeur : un âge inconnu, un champ non renseigné dans une base de données. Le type [int?] (C# 2) est le type [int] auquel on ajoute la valeur [null], qui signifie « pas de valeur ». On parle de type valeur nullable. Le principe vaut pour tous les types valeur : [double?], [bool?], [DateTime?], ...

Une variable d'un type référence, comme [string], peut depuis toujours valoir [null] : cela signifie qu'elle ne référence aucun objet. Utiliser un membre d'une telle variable, par exemple sa propriété [Length], provoque alors une erreur à l'exécution ([NullReferenceException]), l'une des plus fréquentes en C#. Depuis C# 8, le compilateur peut aider à l'éviter : avec les types référence nullables, activés par défaut dans les projets créés par la commande [dotnet new] et dans les applications fichier unique,

  • le type [string] désigne une chaîne qui ne vaut jamais [null] ;
  • le type [string?] désigne une chaîne qui peut valoir [null].

Le compilateur suit les valeurs possibles des variables et émet un avertissement lorsqu'on risque d'utiliser une valeur [null] : affectation de [null] à une variable [string], utilisation de [Length] sur une variable [string?] qui n'a pas été testée, etc. Contrairement à [int?], [string?] ne crée pas un nouveau type à l'exécution : c'est une indication destinée au compilateur.

Nous rencontrerons ces types dès les premiers exemples, car la méthode [Console.ReadLine()], qui lit une ligne tapée au clavier, rend un [string?] : elle rend [null] lorsqu'il n'y a plus rien à lire (fin du flux d'entrée). Le programme [bases-06.cs] est le suivant :

// les types nullables
// un type valeur nullable (int?) accepte la valeur null en plus des entiers
int? age = null;
Console.WriteLine($"age.HasValue={age.HasValue}");
age = 23;
Console.WriteLine($"age.HasValue={age.HasValue}, age.Value={age.Value}");
// int? -> int : il faut une conversion explicite (exception si age vaut null)
int n = (int)age;
Console.WriteLine($"n={n}");

// un type référence nullable (string?) accepte la valeur null
string? prenom = null;
Console.WriteLine($"prenom est null : {prenom == null}");
// le type string (sans ?) est réputé ne jamais valoir null
string nom = "dupont";
Console.WriteLine($"nom a {nom.Length} caractères");

// Console.ReadLine rend un string? : null si le flux d'entrée est épuisé
Console.Write("Tapez une ligne : ");
string? ligne = Console.ReadLine();
if (ligne == null)
{
    Console.WriteLine();
    Console.WriteLine("Pas de ligne : le flux d'entrée est fermé");
}
else
{
    // ici le compilateur sait que ligne n'est pas null
    Console.WriteLine($"Vous avez tapé [{ligne}] : {ligne.Length} caractère(s)");
}

Résultats avec une saisie au clavier :

1
2
3
4
5
6
7
8
> dotnet run bases-06.cs
age.HasValue=False
age.HasValue=True, age.Value=23
n=23
prenom est null : True
nom a 6 caractères
Tapez une ligne : bonjour
Vous avez tapé [bonjour] : 7 caractère(s)

Résultats lorsque l'utilisateur tape Ctrl-D (Ctrl-Z puis Entrée sous Windows), qui ferme le flux d'entrée :

1
2
3
4
5
6
7
8
> dotnet run bases-06.cs
age.HasValue=False
age.HasValue=True, age.Value=23
n=23
prenom est null : True
nom a 6 caractères
Tapez une ligne : 
Pas de ligne : le flux d'entrée est fermé

Commentaires :

  • ligne 3 : [age] ne contient pas de valeur ;
  • ligne 4 : la propriété [HasValue] indique si la variable contient une valeur, la propriété [Value] donne cette valeur ;
  • ligne 8 : un [int?] ne peut pas être affecté directement à un [int] puisqu'il peut valoir [null]. Il faut une conversion explicite, qui lance une exception si [age] vaut [null]. Nous verrons plus loin l'opérateur [??], plus sûr ;
  • ligne 12 : une chaîne qui peut valoir [null] ;
  • ligne 15 : une chaîne qui ne vaut jamais [null]. Écrire [nom = null;] provoquerait un avertissement du compilateur ;
  • ligne 20 : [Console.ReadLine()] rend un [string?]. Déclarer [ligne] de type [string] provoquerait un avertissement ;
  • ligne 21 : on teste la valeur [null] ;
  • ligne 29 : dans la branche [else], le compilateur sait que [ligne] ne vaut pas [null] : il n'émet aucun avertissement pour l'utilisation de [ligne.Length].

3.3.5. Les conversions entre nombres et chaînes de caractères

Conversion
Méthode
nombre -> chaîne
[nombre.ToString()] ou une chaîne interpolée [$"{nombre}"]
chaîne -> int
[int.Parse(chaine)] ou [int.TryParse(chaine, out int nombre)]
chaîne -> long
[long.Parse(chaine)] ou [long.TryParse(chaine, out long nombre)]
chaîne -> double
[double.Parse(chaine)] ou [double.TryParse(chaine, out double nombre)]
chaîne -> float
[float.Parse(chaine)] ou [float.TryParse(chaine, out float nombre)]
chaîne -> decimal
[decimal.Parse(chaine)] ou [decimal.TryParse(chaine, out decimal nombre)]

La conversion d'une chaîne vers un nombre peut échouer si la chaîne ne représente pas un nombre valide. La méthode [Parse] génère alors une erreur fatale appelée exception. Cette erreur peut être gérée par la clause [try / catch] suivante :

1
2
3
4
5
6
7
8
9
try
{
    appel de la fonction susceptible de générer l'exception
}
catch (Exception e)
{
    traiter l'exception e
}
instruction suivante

Si la fonction ne génère pas d'exception, on passe à l'instruction suivante ; sinon, on passe dans le corps de la clause [catch], puis à l'instruction suivante. Nous reviendrons sur la gestion des exceptions dans la section qui lui est consacrée.

La méthode [TryParse] (.NET 2.0) évite l'exception : elle rend le booléen [true] si la conversion a réussi, [false] sinon, et dépose le nombre obtenu dans une variable passée avec le mot clé [out]. Depuis C# 7, cette variable peut être déclarée dans l'appel lui-même : [int.TryParse(chaine, out var nombre)]. Le mode de passage [out] sera expliqué dans la section sur le passage des paramètres. Lorsqu'on s'attend à des saisies erronées, par exemple au clavier, [TryParse] est préférable à [Parse] : une erreur de saisie n'est pas une situation exceptionnelle.

Le programme [bases-07.cs] présente quelques techniques de conversion entre nombres et chaînes de caractères. La fonction [Affiche] y écrit à l'écran la valeur de son paramètre :

// conversions entre nombres et chaînes de caractères
using System.Globalization;

// données
const int i = 10;
const long l = 100000;
const float f = 45.78F;
double d = -14.98;

// nombre --> chaîne
Affiche(i.ToString());
Affiche(l.ToString());
Affiche(f.ToString());
Affiche(d.ToString());

// booléen --> chaîne
const bool b = false;
Affiche(b.ToString());

// chaîne --> int
int i1 = int.Parse("10");
Affiche(i1.ToString());
try
{
    i1 = int.Parse("10.67");
    Affiche(i1.ToString());
}
catch (Exception e)
{
    Affiche("Erreur : " + e.Message);
}

// chaîne --> long
long l1 = long.Parse("100");
Affiche(l1.ToString());
try
{
    l1 = long.Parse("10.675");
    Affiche(l1.ToString());
}
catch (Exception e)
{
    Affiche("Erreur : " + e.Message);
}

// chaîne --> double
double d1 = double.Parse("100,87");
Affiche(d1.ToString());
try
{
    d1 = double.Parse("abcd");
    Affiche(d1.ToString());
}
catch (Exception e)
{
    Affiche("Erreur : " + e.Message);
}

// chaîne --> float
float f1 = float.Parse("100,87");
Affiche(f1.ToString());
try
{
    f1 = float.Parse("abcd");
    Affiche(f1.ToString());
}
catch (Exception e)
{
    Affiche("Erreur : " + e.Message);
}

// TryParse : rend false au lieu de lancer une exception
if (int.TryParse("123", out int i2))
{
    Affiche($"TryParse(\"123\") a réussi : {i2}");
}
if (!int.TryParse("12x", out var i3))
{
    Affiche($"TryParse(\"12x\") a échoué, i3={i3}");
}

// la culture : séparateur décimal de la culture courante
Affiche($"Culture courante : {CultureInfo.CurrentCulture.Name}");
try
{
    d1 = double.Parse("100.87");
    Affiche(d1.ToString());
}
catch (FormatException e)
{
    Affiche("Erreur : " + e.Message);
}
// la culture invariante utilise le point décimal
double d2 = double.Parse("100.87", CultureInfo.InvariantCulture);
Affiche(d2.ToString());
Affiche(d2.ToString(CultureInfo.InvariantCulture));
if (double.TryParse("3.14", CultureInfo.InvariantCulture, out double pi))
{
    Affiche($"pi={pi}");
}

// une fonction locale (voir plus loin) qui affiche son paramètre
static void Affiche(string s)
{
    Console.WriteLine($"S={s}");
}

Résultats :

> dotnet run bases-07.cs
S=10
S=100000
S=45,78
S=-14,98
S=False
S=10
S=Erreur : The input string '10.67' was not in a correct format.
S=100
S=Erreur : The input string '10.675' was not in a correct format.
S=100,87
S=Erreur : The input string 'abcd' was not in a correct format.
S=100,87
S=Erreur : The input string 'abcd' was not in a correct format.
S=TryParse("123") a réussi : 123
S=TryParse("12x") a échoué, i3=0
S=Culture courante : fr-FR
S=Erreur : The input string '100.87' was not in a correct format.
S=100,87
S=100.87
S=pi=3,14

Commentaires :

  • ligne 2 : l'espace de noms [System.Globalization], qui contient la classe [CultureInfo], ne fait pas partie des using implicites ;
  • lignes 5-8 : les données à convertir ;
  • lignes 11-14 : tout objet a une méthode [ToString()] qui le convertit en chaîne ;
  • lignes 25-30 : on gère l'exception que peut lancer [int.Parse]. [e.Message] est le message d'erreur lié à l'exception [e]. Les messages des exceptions .NET sont en anglais : .NET 10 ne fournit pas de traduction française ;
  • ligne 47 : la chaîne utilise la virgule décimale, conformément à la culture française du système ;
  • ligne 73 : [TryParse] réussit et dépose 123 dans la variable [i2], déclarée dans l'appel ;
  • ligne 77 : [TryParse] échoue et rend [false] ; la variable [i3] reçoit alors la valeur 0 ;
  • ligne 83 : la culture courante du programme est fr-FR ;
  • ligne 86 : en culture française, le point n'est pas un séparateur décimal : la conversion échoue ;
  • ligne 94 : la culture invariante [CultureInfo.InvariantCulture] utilise le point décimal, quelle que soit la machine. On l'utilise pour lire et écrire des nombres dans des fichiers ou des échanges entre programmes, qui ne doivent pas dépendre de la langue de l'utilisateur ;
  • lignes 95-96 : le même nombre converti en chaîne selon la culture courante (100,87) et selon la culture invariante (100.87) ;
  • ligne 97 : [TryParse] accepte lui aussi une culture ;
  • lignes 103-105 : une fonction locale. Dans un programme à instructions de haut niveau, on ne peut pas écrire de méthode, puisqu'il n'y a pas de classe ; on écrit à la place des fonctions locales, que l'on peut appeler avant leur définition. Elles sont présentées dans la section « Les fonctions locales et les tuples ».

Le cours de 2008 indiquait que les nombres réels sous forme de chaîne doivent utiliser la virgule : c'est vrai en culture française seulement. Un programme qui lit des nombres doit toujours savoir dans quelle culture ils ont été écrits.

3.3.6. Les tableaux de données

Un tableau C# est un objet permettant de rassembler sous un même identificateur des données de même type. Sa déclaration est la suivante :

Type[] tableau = new Type[n];

[n] est le nombre de données que peut contenir le tableau. La syntaxe [tableau[i]] désigne la donnée n° i, où i appartient à l'intervalle [0, n-1]. Toute référence à la donnée [tableau[i]] où i n'appartient pas à l'intervalle [0, n-1] provoque une exception. À sa création, chaque élément du tableau reçoit la valeur par défaut de son type : 0 pour les nombres, [false] pour les booléens, [null] pour les types référence. Un tableau peut être initialisé en même temps que déclaré :

int[] entiers = new int[] { 0, 10, 20, 30 };

ou plus simplement :

int[] entiers = { 0, 10, 20, 30 };

ou encore, depuis C# 12, avec une expression de collection :

int[] entiers = [0, 10, 20, 30];

C'est cette dernière forme que nous utiliserons désormais. Les tableaux ont une propriété [Length] qui est leur nombre d'éléments.

Un tableau à deux dimensions est déclaré comme suit :

Type[,] tableau = new Type[n, m];

où [n] est le nombre de lignes et [m] le nombre de colonnes. La syntaxe [tableau[i, j]] désigne l'élément j de la ligne i. Le tableau à deux dimensions peut lui aussi être initialisé en même temps qu'il est déclaré :

double[,] reels = { { 0.5, 1.7 }, { 8.4, -6 } };

Le nombre d'éléments de chacune des dimensions est obtenu par la méthode [GetLength(i)], où i=0 représente la dimension correspondant au 1er indice, i=1 la dimension correspondant au 2e indice, etc. Le nombre de dimensions est obtenu avec la propriété [Rank], le nombre total d'éléments avec la propriété [Length].

Un tableau de tableaux (on dit aussi tableau en escalier) est déclaré comme suit :

Type[][] tableau = new Type[n][];

La déclaration ci-dessus crée un tableau de n éléments. Chaque élément [tableau[i]] est une référence de tableau à une dimension. Ces références ne sont pas initialisées par la déclaration ci-dessus : elles ont la valeur [null]. Contrairement à un tableau à deux dimensions, chaque ligne peut avoir une longueur différente.

C# 8 a ajouté deux notations commodes :

  • l'index depuis la fin : [tableau[^1]] est le dernier élément, [tableau[^2]] l'avant-dernier, etc. [tableau[^1]] équivaut à [tableau[tableau.Length - 1]] ;
  • la plage : [tableau[1..3]] est un nouveau tableau formé des éléments d'indices 1 et 2. La borne de début est incluse, la borne de fin exclue. Les bornes peuvent être omises : [tableau[2..]] va de l'indice 2 jusqu'à la fin, [tableau[..^1]] du début jusqu'à l'avant-dernier élément inclus.

Le programme [bases-08.cs] regroupe ces différents tableaux :

// les tableaux
// un tableau à 1 dimension initialisé
int[] entiers = new int[] { 0, 10, 20, 30 };
for (int i = 0; i < entiers.Length; i++)
{
    Console.WriteLine($"entiers[{i}]={entiers[i]}");
}

// un tableau à 2 dimensions initialisé
double[,] reels = { { 0.5, 1.7 }, { 8.4, -6 } };
for (int i = 0; i < reels.GetLength(0); i++)
{
    for (int j = 0; j < reels.GetLength(1); j++)
    {
        Console.WriteLine($"reels[{i},{j}]={reels[i, j]}");
    }
}
Console.WriteLine($"reels : Rank={reels.Rank}, Length={reels.Length}");

// un tableau de tableaux (tableau en escalier)
string[][] noms = new string[3][];
for (int i = 0; i < noms.Length; i++)
{
    noms[i] = new string[i + 1];
}
// initialisation
for (int i = 0; i < noms.Length; i++)
{
    for (int j = 0; j < noms[i].Length; j++)
    {
        noms[i][j] = "nom" + i + j;
    }
}
// affichage
for (int i = 0; i < noms.Length; i++)
{
    for (int j = 0; j < noms[i].Length; j++)
    {
        Console.WriteLine($"noms[{i}][{j}]={noms[i][j]}");
    }
}

// un tableau créé sans valeurs initiales : éléments à la valeur par défaut du type
bool[] drapeaux = new bool[3];
string?[] chaines = new string?[2];
Console.WriteLine($"drapeaux[0]={drapeaux[0]}, chaines[0] est null : {chaines[0] == null}");

// expression de collection (C# 12)
int[] valeurs = [0, 10, 20, 30, 40];
// string.Join assemble les éléments d'un tableau avec un séparateur
Console.WriteLine($"valeurs=[{string.Join(", ", valeurs)}]");
// index depuis la fin (C# 8) : ^1 désigne le dernier élément
Console.WriteLine($"valeurs[^1]={valeurs[^1]}, valeurs[^2]={valeurs[^2]}");
// plage (C# 8) : les éléments d'indices 1 et 2 (la borne de fin est exclue)
int[] extrait = valeurs[1..3];
Console.WriteLine($"valeurs[1..3]=[{string.Join(", ", extrait)}]");
Console.WriteLine($"valeurs[2..]=[{string.Join(", ", valeurs[2..])}]");
Console.WriteLine($"valeurs[..^1]=[{string.Join(", ", valeurs[..^1])}]");
// l'extrait est une copie : le tableau d'origine n'est pas modifié
extrait[0] = 100;
Console.WriteLine($"extrait[0]={extrait[0]}, valeurs[1]={valeurs[1]}");

Résultats :

> dotnet run bases-08.cs
entiers[0]=0
entiers[1]=10
entiers[2]=20
entiers[3]=30
reels[0,0]=0,5
reels[0,1]=1,7
reels[1,0]=8,4
reels[1,1]=-6
reels : Rank=2, Length=4
noms[0][0]=nom00
noms[1][0]=nom10
noms[1][1]=nom11
noms[2][0]=nom20
noms[2][1]=nom21
noms[2][2]=nom22
drapeaux[0]=False, chaines[0] est null : True
valeurs=[0, 10, 20, 30, 40]
valeurs[^1]=40, valeurs[^2]=30
valeurs[1..3]=[10, 20]
valeurs[2..]=[20, 30, 40]
valeurs[..^1]=[0, 10, 20, 30]
extrait[0]=100, valeurs[1]=10

Commentaires :

  • ligne 3 : un tableau à une dimension initialisé ;
  • ligne 4 : une boucle [for] parcourt les indices de 0 à [entiers.Length - 1]. La boucle [for] est présentée plus loin ;
  • ligne 10 : un tableau à deux dimensions (2 lignes, 2 colonnes) ;
  • lignes 11-13 : [GetLength(0)] est le nombre de lignes, [GetLength(1)] le nombre de colonnes ;
  • ligne 21 : un tableau [noms] de 3 éléments. Chaque élément est une référence sur un tableau de type [string[]], pour l'instant [null] ;
  • ligne 24 : les 3 éléments du tableau [noms] sont initialisés. [noms[i]] référence désormais un tableau de i+1 chaînes ;
  • ligne 31 : [noms[i][j]] est l'élément j du tableau référencé par [noms[i]]. Comme [noms[i]] est un tableau, [noms[i].Length] est son nombre d'éléments. L'expression "nom" + i + j est évaluée de gauche à droite : "nom" + i donne une chaîne, à laquelle on concatène j ;
  • lignes 44-45 : des tableaux créés sans valeurs initiales. Le type [string?[]] est un tableau dont les éléments peuvent valoir [null] ;
  • ligne 49 : une expression de collection ;
  • ligne 51 : la méthode [string.Join] construit une chaîne avec les éléments du tableau, séparés par la chaîne passée en premier paramètre ;
  • ligne 53 : le dernier et l'avant-dernier élément ;
  • ligne 55 : les éléments d'indices 1 et 2 ;
  • lignes 60-61 : une plage crée une copie : modifier [extrait] ne modifie pas [valeurs].

3.4. Les instructions élémentaires de C#

On distingue :

  • les instructions élémentaires exécutées par l'ordinateur ;
  • les instructions de contrôle du déroulement du programme.

Les instructions élémentaires apparaissent clairement lorsqu'on considère la structure d'un micro-ordinateur et de ses périphériques :

Image

  • 1 : lecture d'informations provenant du clavier ;
  • 2 : traitement d'informations ;
  • 3 : écriture d'informations à l'écran.

3.4.1. Écriture sur écran

Il existe différentes instructions d'écriture à l'écran :

1
2
3
Console.Out.WriteLine(expression);
Console.WriteLine(expression);
Console.Error.WriteLine(expression);

où [expression] est toute donnée qui puisse être convertie en chaîne de caractères pour être affichée à l'écran. Tous les objets de C# ou .NET ont une méthode [ToString()] qui est utilisée pour faire cette conversion. La méthode [Write] fait la même chose que [WriteLine] sans passer à la ligne.

La classe [System.Console] donne accès aux opérations d'écriture à l'écran ([Write], [WriteLine]). Elle a deux propriétés [Out] et [Error] qui sont des flux d'écriture de type [TextWriter] :

  • [Console.WriteLine()] est équivalent à [Console.Out.WriteLine()] et écrit sur le flux [Out], associé habituellement à l'écran ;
  • [Console.Error.WriteLine()] écrit sur le flux [Error], habituellement associé lui aussi à l'écran.

Les flux [Out] et [Error] peuvent être redirigés vers des fichiers au moment de l'exécution du programme, comme nous le verrons prochainement.

3.4.2. Lecture de données tapées au clavier

Le flux de données provenant du clavier est désigné par l'objet [Console.In] de type [TextReader]. Ce type d'objet permet de lire une ligne de texte avec la méthode [ReadLine] :

string? ligne = Console.In.ReadLine();

La classe [Console] offre une méthode [ReadLine] associée par défaut au flux [In]. On peut donc écrire :

string? ligne = Console.ReadLine();

La ligne tapée au clavier, sans la marque de fin de ligne, est rangée dans la variable [ligne] et peut ensuite être exploitée par le programme. Comme nous l'avons vu, [ReadLine] rend [null] lorsque le flux d'entrée est épuisé : c'est pourquoi la variable est de type [string?]. Le flux [In] peut être redirigé vers un fichier, comme les flux [Out] et [Error].

3.4.3. Exemple d'entrées-sorties

Le programme [bases-09.cs] illustre les opérations d'entrées-sorties clavier / écran :

// entrées-sorties clavier / écran

// écriture sur le flux Out
object obj = new object();
Console.Out.WriteLine(obj);

// écriture sur le flux Error
int i = 10;
Console.Error.WriteLine("i=" + i);
Console.Error.WriteLine("i={0}", i);

// lecture d'une ligne saisie au clavier
Console.Write("Tapez une ligne : ");
string? ligne = Console.ReadLine();
Console.WriteLine($"ligne={ligne}");

Résultats :

1
2
3
4
5
6
> dotnet run bases-09.cs
System.Object
i=10
i=10
Tapez une ligne : je suis là
ligne=je suis là

Commentaires :

  • ligne 4 : [obj] est une référence d'objet ;
  • ligne 5 : [obj] est écrit à l'écran. C'est la méthode [obj.ToString()] qui est appelée pour obtenir le texte à afficher ;
  • ligne 9 : on écrit sur le flux [Error] ;
  • ligne 10 : la même chose avec un format. Le premier paramètre "i={0}" est le format d'affichage, les autres paramètres sont les expressions à afficher. Les éléments {n} sont des paramètres positionnels : à l'exécution, {n} est remplacé par la valeur de l'expression n° n ;
  • lignes 13-14 : on affiche une invite sans passer à la ligne, puis on lit la ligne tapée au clavier ;
  • ligne 15 : dans une chaîne interpolée, une valeur [null] produirait une chaîne vide : aucun avertissement n'est émis.

Dans les résultats :

  • ligne 2 : l'affichage produit par la ligne 5 du code. La méthode [obj.ToString()] a affiché le nom du type de la variable [obj] : [System.Object]. Le type [object] est un alias C# du type .NET [System.Object] ;
  • lignes 3-4 : les deux écritures sur le flux [Error] apparaissent à l'écran comme celles du flux [Out].

3.4.4. Redirection des entrées-sorties

Windows, Linux et macOS connaissent trois périphériques standard :

  • le périphérique d'entrée standard, qui désigne par défaut le clavier et porte le n° 0 ;
  • le périphérique de sortie standard, qui désigne par défaut l'écran et porte le n° 1 ;
  • le périphérique d'erreur standard, qui désigne par défaut l'écran et porte le n° 2.

En C#, le flux d'écriture [Console.Out] écrit sur le périphérique 1, le flux d'écriture [Console.Error] écrit sur le périphérique 2 et le flux de lecture [Console.In] lit les données provenant du périphérique 0.

Lorsqu'on lance un programme dans une fenêtre de commandes, on peut fixer quels seront les périphériques 0, 1 et 2 pour le programme exécuté. Derrière les arguments du programme, on peut rediriger les périphériques d'entrées-sorties standard vers des fichiers :

Redirection
Signification
0<in.txt ou <in.txt
le flux d'entrée standard n° 0 est redirigé vers le fichier in.txt. Dans le programme, le flux [Console.In] prendra donc ses données dans le fichier in.txt
1>out.txt ou >out.txt
redirige la sortie n° 1 vers le fichier out.txt. Dans le programme, le flux [Console.Out] écrira donc ses données dans le fichier out.txt
1>>out.txt ou >>out.txt
idem, mais les données écrites sont ajoutées au contenu actuel du fichier out.txt
2>err.txt
redirige la sortie n° 2 vers le fichier err.txt. Dans le programme, le flux [Console.Error] écrira donc ses données dans le fichier err.txt
2>>err.txt
idem, mais les données écrites sont ajoutées au contenu actuel du fichier err.txt
1>out.txt 2>err.txt
les périphériques 1 et 2 sont tous les deux redirigés vers des fichiers

On notera que pour rediriger les flux d'entrées-sorties d'un programme vers des fichiers, le programme n'a pas besoin d'être modifié : c'est le système d'exploitation qui fixe la nature des périphériques 0, 1 et 2. Considérons le programme [bases-10.cs] suivant :

1
2
3
4
5
6
7
// redirections des flux d'entrée-sortie
// lecture flux In
string? data = Console.In.ReadLine();
// écriture flux Out
Console.Out.WriteLine("écriture dans flux Out : " + data);
// écriture flux Error
Console.Error.WriteLine("écriture dans flux Error : " + data);

Le cours de 2008 générait un exécutable avec Visual Studio puis l'exécutait dans une fenêtre DOS. Aujourd'hui, la commande [dotnet run] suffit : les redirections s'appliquent au programme lancé. Voici une session dans un terminal Linux (bash) :

> echo test > in.txt
> cat in.txt
test
> dotnet run bases-10.cs < in.txt > out.txt 2> err.txt
> cat out.txt
écriture dans flux Out : test
> cat err.txt
écriture dans flux Error : test
> dotnet run bases-10.cs < in.txt 2> err.txt
écriture dans flux Out : test
> dotnet run bases-10.cs < in.txt > out.txt
écriture dans flux Error : test

Commentaires :

  • ligne 1 : on met la chaîne test dans le fichier in.txt ;
  • ligne 2 : on affiche le contenu du fichier in.txt pour vérification ;
  • ligne 4 : exécution du programme. Le flux [In] est redirigé vers le fichier in.txt, le flux [Out] vers le fichier out.txt, le flux [Error] vers le fichier err.txt. L'exécution ne provoque aucun affichage ;
  • lignes 5-6 : le contenu du fichier out.txt montre que le fichier in.txt a été lu et que l'écriture sur le flux [Out] a été redirigée vers out.txt ;
  • lignes 7-8 : vérification analogue pour le fichier err.txt ;
  • lignes 9-10 : seul le flux [Error] est redirigé : l'écran n'affiche que l'écriture sur le flux [Out] ;
  • lignes 11-12 : seul le flux [Out] est redirigé : l'écran n'affiche que l'écriture sur le flux [Error].

On voit clairement que les flux [Out] et [Error] n'écrivent pas sur les mêmes périphériques puisqu'on a pu les rediriger séparément. Le flux [Error] est fait pour les messages d'erreur : on peut ainsi rediriger les résultats d'un programme vers un fichier tout en continuant à voir ses messages d'erreur à l'écran.

Les opérateurs >, >> et 2> s'écrivent de la même façon dans l'invite de commandes Windows ([cmd]), dans PowerShell et dans bash. L'opérateur < est reconnu par [cmd] et bash, mais pas par PowerShell, qui le réserve pour un usage futur : on y envoie un fichier sur l'entrée standard d'un programme avec un tube (|). Les commandes suivantes, données à titre d'illustration (elles n'ont pas été exécutées pour ce cours), font sous Windows ce que fait la session bash ci-dessus :

rem invite de commandes Windows (cmd)
echo test>in.txt
dotnet run bases-10.cs <in.txt >out.txt 2>err.txt
type out.txt
type err.txt

# PowerShell
"test" > in.txt
Get-Content in.txt | dotnet run bases-10.cs > out.txt 2> err.txt
Get-Content out.txt
Get-Content err.txt

3.4.5. Affectation de la valeur d'une expression à une variable

On s'intéresse ici à l'opération [variable = expression;]. L'expression peut être de type arithmétique, relationnelle, booléenne, caractères, etc.

3.4.5.1. Interprétation de l'opération d'affectation

L'opération [variable = expression;] est elle-même une expression dont l'évaluation se déroule de la façon suivante :

  • la partie droite de l'affectation est évaluée : le résultat est une valeur V ;
  • la valeur V est affectée à la variable ;
  • la valeur V est aussi la valeur de l'affectation, vue cette fois en tant qu'expression.

C'est ainsi que l'opération [V1 = V2 = expression] est légale. L'opérateur = étant évalué de droite à gauche, on a [V1 = (V2 = expression)]. L'expression [V2 = expression] est évaluée et a pour valeur V. Son évaluation a provoqué l'affectation de V à V2. L'opérateur = suivant est alors évalué sous la forme [V1 = V]. La valeur de cette expression est encore V ; son évaluation provoque l'affectation de V à V1. Ainsi donc, l'opération [V1 = V2 = expression] est une expression dont l'évaluation :

  • provoque l'affectation de la valeur de [expression] aux variables V1 et V2 ;
  • rend comme résultat la valeur de [expression].

On peut généraliser à une expression du type [V1 = V2 = ... = Vn = expression].

3.4.5.2. Expressions arithmétiques

Les opérateurs des expressions arithmétiques sont les suivants :

Opérateur
Rôle
+
addition
-
soustraction
*
multiplication
/
division : le résultat est le quotient exact si l'un au moins des opérandes est réel. Si les deux opérandes sont entiers, le résultat est le quotient entier. Ainsi 5/2 vaut 2 et 5.0/2 vaut 2.5
%
reste de la division (modulo), quelle que soit la nature des opérandes. Le reste a le signe du dividende : -5 % 2 vaut -1

Il existe diverses fonctions mathématiques. En voici quelques-unes :

Fonction
Rôle
[double Sqrt(double x)]
racine carrée
[double Cos(double x)], [Sin], [Tan]
cosinus, sinus, tangente (angle en radians)
[double Pow(double x, double y)]
x à la puissance y
[double Exp(double x)]
exponentielle
[double Log(double x)], [Log10], [Log2]
logarithme népérien, décimal, en base 2
[Abs(x)], [Max(x, y)], [Min(x, y)]
valeur absolue, maximum, minimum (pour tous les types numériques)
[Floor(x)], [Ceiling(x)], [Truncate(x)]
partie entière inférieure, supérieure, troncature
[Round(x)], [Round(x, n)]
arrondi à l'entier, à n décimales
[Math.PI], [Math.E]
les constantes π et e

Toutes ces fonctions sont définies dans une classe .NET appelée [Math]. Lorsqu'on les utilise, il faut les préfixer avec le nom de la classe où elles sont définies. Ainsi on écrira :

double x, y = 4;
x = Math.Sqrt(y);

Le cours de 2008 reproduisait ici la définition complète de la classe [Math] : on la trouve aujourd'hui dans la documentation en ligne de .NET, learn.microsoft.com/dotnet/api/system.math.

Le programme [bases-11.cs] est le suivant :

// expressions arithmétiques
// division entière et division réelle
Console.WriteLine($"5 / 2 = {5 / 2}, 5.0 / 2 = {5.0 / 2}, 5 / 2.0 = {5 / 2.0}");
// reste de la division (modulo)
Console.WriteLine($"5 % 2 = {5 % 2}, -5 % 2 = {-5 % 2}, 5.5 % 2 = {5.5 % 2}");
// division réelle par zéro : pas d'erreur, l'infini ou NaN (Not a Number)
double zero = 0;
Console.WriteLine($"1 / 0.0 = {1 / zero}, 0 / 0.0 = {0 / zero}");

// quelques fonctions de la classe Math
double x, y = 4;
x = Math.Sqrt(y);
Console.WriteLine($"Math.Sqrt(4) = {x}");
Console.WriteLine($"Math.Pow(2, 10) = {Math.Pow(2, 10)}");
Console.WriteLine($"Math.Abs(-3.5) = {Math.Abs(-3.5)}, Math.Max(3, 7) = {Math.Max(3, 7)}");
Console.WriteLine($"Math.PI = {Math.PI}, Math.Cos(Math.PI) = {Math.Cos(Math.PI)}");
Console.WriteLine($"Math.Exp(1) = {Math.Exp(1)}, Math.Log(Math.E) = {Math.Log(Math.E)}");
Console.WriteLine($"Math.Floor(-2.5) = {Math.Floor(-2.5)}, Math.Ceiling(-2.5) = {Math.Ceiling(-2.5)}, Math.Truncate(-2.5) = {Math.Truncate(-2.5)}");
// Math.Round arrondit par défaut au nombre pair le plus proche
Console.WriteLine($"Math.Round(2.5) = {Math.Round(2.5)}, Math.Round(3.5) = {Math.Round(3.5)}");
Console.WriteLine($"Math.Round(2.5, MidpointRounding.AwayFromZero) = {Math.Round(2.5, MidpointRounding.AwayFromZero)}");
Console.WriteLine($"Math.Round(2.567, 2) = {Math.Round(2.567, 2)}");

// priorités : * / % avant + -
Console.WriteLine($"2 + 3 * 4 = {2 + 3 * 4}, (2 + 3) * 4 = {(2 + 3) * 4}, 10 - 4 - 3 = {10 - 4 - 3}");

// l'affectation est une expression : affectation multiple
int v1, v2;
v1 = v2 = 7 * 6;
Console.WriteLine($"v1={v1}, v2={v2}");

Résultats :

> dotnet run bases-11.cs
5 / 2 = 2, 5.0 / 2 = 2,5, 5 / 2.0 = 2,5
5 % 2 = 1, -5 % 2 = -1, 5.5 % 2 = 1,5
1 / 0.0 = ∞, 0 / 0.0 = NaN
Math.Sqrt(4) = 2
Math.Pow(2, 10) = 1024
Math.Abs(-3.5) = 3,5, Math.Max(3, 7) = 7
Math.PI = 3,141592653589793, Math.Cos(Math.PI) = -1
Math.Exp(1) = 2,718281828459045, Math.Log(Math.E) = 1
Math.Floor(-2.5) = -3, Math.Ceiling(-2.5) = -2, Math.Truncate(-2.5) = -2
Math.Round(2.5) = 2, Math.Round(3.5) = 4
Math.Round(2.5, MidpointRounding.AwayFromZero) = 3
Math.Round(2.567, 2) = 2,57
2 + 3 * 4 = 14, (2 + 3) * 4 = 20, 10 - 4 - 3 = 3
v1=42, v2=42

Commentaires :

  • ligne 3 : la division de deux entiers est une division entière ;
  • ligne 5 : le reste a le signe du dividende ; l'opérateur % s'applique aussi aux réels ;
  • ligne 8 : la division d'un réel par zéro ne provoque pas d'erreur : elle donne l'infini (affiché ∞) ou la valeur spéciale NaN (« Not a Number ») pour 0/0. En revanche, la division d'un entier par zéro lance une exception [DivideByZeroException] ;
  • lignes 11-12 : l'exemple ci-dessus ;
  • ligne 18 : [Floor] arrondit vers le bas, [Ceiling] vers le haut, [Truncate] vers zéro ;
  • ligne 20 : par défaut, [Math.Round] arrondit une valeur située exactement à mi-chemin vers le nombre pair le plus proche (arrondi « bancaire ») : 2,5 donne 2 et 3,5 donne 4 ;
  • ligne 21 : pour l'arrondi enseigné à l'école (2,5 donne 3), on précise le mode d'arrondi ;
  • ligne 25 : la priorité des opérateurs, présentée ci-dessous ;
  • ligne 29 : une affectation multiple.

3.4.5.3. Priorités dans l'évaluation des expressions arithmétiques

La priorité des opérateurs lors de l'évaluation d'une expression arithmétique est la suivante (du plus prioritaire au moins prioritaire) :

[fonctions], [( )], [*, /, %], [+, -]

Les opérateurs d'un même bloc [ ] ont même priorité. À priorité égale, l'évaluation se fait de gauche à droite : 10 - 4 - 3 vaut (10 - 4) - 3 = 3.

3.4.5.4. Expressions relationnelles

Les opérateurs sont les suivants : <, <=, ==, !=, >, >=. Leurs priorités :

  • >, >=, <, <= ;
  • ==, !=.

Le résultat d'une expression relationnelle est le booléen [false] si l'expression est fausse, [true] sinon.

1
2
3
bool fin;
int x = 5;
fin = x > 4;

Comparaison de deux caractères

Soient deux caractères C1 et C2. Il est possible de les comparer avec les opérateurs <, <=, ==, !=, >, >=. Ce sont alors leurs codes Unicode, qui sont des nombres, qui sont comparés. Selon l'ordre Unicode, on a les relations suivantes :

espace < .. < '0' < '1' < .. < '9' < .. < 'A' < 'B' < .. < 'Z' < .. < 'a' < 'b' < .. < 'z' < .. < 'é'

Comparaison de deux chaînes de caractères

Elles sont comparées caractère par caractère. La première inégalité rencontrée entre deux caractères induit une inégalité de même sens sur les chaînes. Soit à comparer les chaînes "Chat" et "Chien" :

1
2
3
4
5
"Chat"    "Chien"
---------------------
'C'   =   'C'
'h'   =   'h'
'a'   <   'i'

Cette dernière inégalité permet de dire que "Chat" < "Chien". Soit à comparer les chaînes "Chat" et "Chaton". Il y a égalité jusqu'à épuisement de la chaîne "Chat". Dans ce cas, la chaîne épuisée est déclarée la plus « petite ». On a donc la relation "Chat" < "Chaton".

Fonctions de comparaison de deux chaînes

On peut utiliser les opérateurs relationnels == et != pour tester l'égalité ou non de deux chaînes, ou bien la méthode [Equals] de la classe [System.String]. Pour les relations < <= > >=, les opérateurs ne s'appliquent pas aux chaînes : il faut utiliser la méthode [CompareTo] de la classe [System.String] ou la méthode statique [string.Compare]. Elles rendent un entier :

  • nul si les deux chaînes sont égales ;
  • positif si la chaîne n° 1 est supérieure à la chaîne n° 2 ;
  • négatif si la chaîne n° 1 est inférieure à la chaîne n° 2.

Il y a une subtilité : [CompareTo] et [string.Compare] ne comparent pas simplement les codes Unicode. Ils suivent les règles de tri de la culture courante, celles d'un dictionnaire : en français, "élan" se range avant "face", bien que le code de 'é' (233) soit supérieur à celui de 'f' (102). Pour choisir le mode de comparaison, [string.Compare] et [Equals] acceptent un paramètre de type [StringComparison] :

  • [StringComparison.Ordinal] : comparaison des codes Unicode, caractère par caractère. C'est la comparaison la plus rapide, à utiliser pour les identifiants, les mots clés, les noms de fichiers ;
  • [StringComparison.OrdinalIgnoreCase] : idem, sans tenir compte de la différence entre majuscules et minuscules ;
  • [StringComparison.CurrentCulture] (le défaut de [CompareTo]) : selon les règles de la culture courante, pour trier des textes destinés à l'utilisateur.

L'opérateur == et la méthode [Equals] sans paramètre font une comparaison ordinale.

Le programme [bases-12.cs] est le suivant :

// expressions relationnelles et comparaison de chaînes
int x = 5;
bool fin = x > 4;
Console.WriteLine($"x > 4 : {fin}, x == 5 : {x == 5}, x != 5 : {x != 5}");

// comparaison de caractères : on compare leurs codes Unicode
Console.WriteLine($"'a' < 'b' : {'a' < 'b'}, 'Z' < 'a' : {'Z' < 'a'}, '9' < 'A' : {'9' < 'A'}");
Console.WriteLine($"codes : 'A'={(int)'A'}, 'a'={(int)'a'}, 'é'={(int)'é'}");

// comparaison de chaînes
string chaine1 = "chat", chaine2 = "chien";
int n = chaine1.CompareTo(chaine2);
bool egal = chaine1.Equals(chaine2);
Console.WriteLine($"n={n}, egal={egal}");
Console.WriteLine($"\"chien\"==chaine1 : {"chien" == chaine1}, \"chien\"!=chaine2 : {"chien" != chaine2}");
Console.WriteLine($"\"Chat\".CompareTo(\"Chaton\") : {"Chat".CompareTo("Chaton")}");

// CompareTo et string.Compare suivent les règles de la culture courante (fr-FR)
Console.WriteLine($"\"élan\".CompareTo(\"face\") : {"élan".CompareTo("face")}");
Console.WriteLine($"string.Compare(\"Chat\", \"chat\") : {string.Compare("Chat", "chat")}");
// StringComparison.Ordinal : comparaison des codes Unicode, caractère par caractère
Console.WriteLine($"Ordinal(\"élan\", \"face\") : {string.Compare("élan", "face", StringComparison.Ordinal)}");
Console.WriteLine($"Ordinal(\"Chat\", \"chat\") : {string.Compare("Chat", "chat", StringComparison.Ordinal)}");
// sans tenir compte de la casse (majuscules / minuscules)
Console.WriteLine($"OrdinalIgnoreCase(\"Chat\", \"chat\") : {string.Compare("Chat", "chat", StringComparison.OrdinalIgnoreCase)}");
Console.WriteLine($"\"Chat\".Equals(\"chat\") : {"Chat".Equals("chat")}");
Console.WriteLine($"\"Chat\".Equals(\"chat\", OrdinalIgnoreCase) : {"Chat".Equals("chat", StringComparison.OrdinalIgnoreCase)}");

Résultats :

> dotnet run bases-12.cs
x > 4 : True, x == 5 : True, x != 5 : False
'a' < 'b' : True, 'Z' < 'a' : True, '9' < 'A' : True
codes : 'A'=65, 'a'=97, 'é'=233
n=-1, egal=False
"chien"==chaine1 : False, "chien"!=chaine2 : False
"Chat".CompareTo("Chaton") : -1
"élan".CompareTo("face") : -1
string.Compare("Chat", "chat") : 1
Ordinal("élan", "face") : 131
Ordinal("Chat", "chat") : -32
OrdinalIgnoreCase("Chat", "chat") : 0
"Chat".Equals("chat") : False
"Chat".Equals("chat", OrdinalIgnoreCase) : True

Commentaires :

  • ligne 3 : une expression relationnelle ;
  • ligne 7 : la comparaison de caractères ;
  • ligne 8 : la conversion [(int)] d'un caractère donne son code Unicode ;
  • ligne 12 : la variable [n] reçoit un entier négatif puisque "chat" < "chien" ;
  • ligne 13 : la variable [egal] a la valeur [true] si les deux chaînes sont égales, [false] sinon ;
  • ligne 15 : on utilise les opérateurs == et != pour vérifier l'égalité ou non de deux chaînes. Dans une chaîne interpolée, \" désigne un guillemet ;
  • ligne 16 : la chaîne épuisée la première est la plus petite ;
  • lignes 19-20 : comparaisons selon la culture française : "élan" précède "face", et "Chat" suit "chat" (à lettres égales, la minuscule passe avant la majuscule) ;
  • lignes 22-23 : comparaisons ordinales : le résultat est ici la différence des codes des premiers caractères différents : 233 - 102 = 131 et 67 - 99 = -32 ;
  • lignes 25-27 : en ignorant la casse, "Chat" et "chat" sont égales.

3.4.5.5. Expressions booléennes

Les opérateurs utilisables sont ET (&&), OU (||) et NON (!). Le résultat d'une expression booléenne est un booléen. Priorités des opérateurs :

  • ! ;
  • && ;
  • ||.
double x = 3.5;
bool valide = x > 2 && x < 4;

Les opérateurs relationnels ont priorité sur les opérateurs && et ||. Ces deux opérateurs n'évaluent leur opérande droit que si c'est nécessaire : dans [a && b], b n'est pas évalué si a est faux ; dans [a || b], b n'est pas évalué si a est vrai. On dit qu'ils sont « court-circuités ». On peut ainsi écrire sans risque [texte != null && texte.Length > 0].

3.4.5.6. Traitement de bits

Soient [i] et [j] deux entiers :

Opération
Rôle
i << n
décale i de n bits sur la gauche. Les bits entrants sont des zéros
i >> n
décale i de n bits sur la droite. Si i est un entier signé ([sbyte], [short], [int], [long]), le bit de signe est préservé
i >>> n
décalage à droite non signé (C# 11) : les bits entrants sont des zéros, même si i est signé
i & j
fait le ET logique de i et j bit à bit
i | j
fait le OU logique de i et j bit à bit
~i
complémente i à 1
i ^ j
fait le OU EXCLUSIF de i et j bit à bit

Le programme [bases-13.cs] est le suivant :

// traitement de bits
short i = 100, j = -13;
ushort k = 0xF123;
Console.WriteLine("i=0x{0:x4}, j=0x{1:x4}, k=0x{2:x4}", i, j, k);
Console.WriteLine("i<<4=0x{0:x4}, i>>4=0x{1:x4}, k>>4=0x{2:x4}", i << 4, i >> 4, k >> 4);
Console.WriteLine("i&j=0x{0:x4}, i|j=0x{1:x4}, i^j=0x{2:x4}, ~i=0x{3:x4}",
    (short)(i & j), (short)(i | j), (short)(i ^ j), (short)~i);
Console.WriteLine("j<<2=0x{0:x4}, j>>2=0x{1:x4}", (short)(j << 2), (short)(j >> 2));
// les mêmes valeurs en binaire (format B, .NET 8)
Console.WriteLine($"i={i:B16}, j={j:B16}, i&j={(short)(i & j):B16}");
// décalage à droite non signé >>> (C# 11) : les bits entrants sont des zéros
int m = -16;
Console.WriteLine($"m={m:B32}, m>>2={m >> 2:B32}, m>>>2={m >>> 2:B32}");

Résultats :

1
2
3
4
5
6
7
> dotnet run bases-13.cs
i=0x0064, j=0xfff3, k=0xf123
i<<4=0x0640, i>>4=0x0006, k>>4=0x0f12
i&j=0x0060, i|j=0xfff7, i^j=0xff97, ~i=0xff9b
j<<2=0xffcc, j>>2=0xfffc
i=0000000001100100, j=1111111111110011, i&j=0000000001100000
m=11111111111111111111111111110000, m>>2=11111111111111111111111111111100, m>>>2=00111111111111111111111111111100

Commentaires :

  • ligne 4 : le format {0:x4} affiche le paramètre n° 0 en hexadécimal (x) avec 4 chiffres (4) ;
  • ligne 7 : les opérations sur les bits d'un [short] donnent un [int] : on les reconvertit en [short] pour afficher 4 chiffres hexadécimaux. Sans conversion, ~i serait affiché sur 8 chiffres (ffffff9b) ;
  • ligne 10 : le format B16 affiche un entier en binaire sur 16 chiffres. On vérifie bit à bit le ET logique de i et j ;
  • ligne 13 : le décalage [>>] préserve le bit de signe de m, qui est négatif ; le décalage [>>>] fait entrer des zéros.

3.4.5.7. Combinaison d'opérateurs

[a = a + b] peut s'écrire [a += b] ; [a = a - b] peut s'écrire [a -= b]. Il en est de même avec les opérateurs /, %, *, <<, >>, >>>, &, |, ^. Ainsi [a = a / 2;] peut s'écrire [a /= 2;].

3.4.5.8. Opérateurs d'incrémentation et de décrémentation

La notation [variable++] signifie [variable = variable + 1] ou encore [variable += 1]. La notation [variable--] signifie [variable = variable - 1] ou encore [variable -= 1]. Utilisés dans une expression, ces opérateurs ont deux formes :

  • [i++] (postfixée) : la valeur de l'expression est la valeur de i avant l'incrémentation ;
  • [++i] (préfixée) : la valeur de l'expression est la valeur de i après l'incrémentation.

3.4.5.9. L'opérateur ternaire ?

L'expression [expr_cond ? expr1 : expr2] est évaluée de la façon suivante :

  • l'expression [expr_cond] est évaluée. C'est une expression booléenne ;
  • si elle est vraie, la valeur de l'expression est celle de [expr1], et [expr2] n'est pas évaluée ;
  • si elle est fausse, c'est l'inverse qui se produit : la valeur de l'expression est celle de [expr2], et [expr1] n'est pas évaluée.

L'opération [i = (j > 4 ? j + 1 : j - 1);] affectera à la variable i : j+1 si j>4, j-1 sinon. C'est la même chose que d'écrire [if (j > 4) i = j + 1; else i = j - 1;], mais c'est plus concis.

3.4.5.10. Les opérateurs ??, ??= et ?.

Ces opérateurs facilitent le travail avec les valeurs [null] :

  • [a ?? b] (C# 2) vaut a si a ne vaut pas [null], b sinon. On l'utilise pour donner une valeur de remplacement : [string nom = Console.ReadLine() ?? "";]. Le résultat n'est plus nullable ;
  • [a ??= b] (C# 8) affecte b à a seulement si a vaut [null] ;
  • [a?.Membre] (C# 6) vaut [null] si a vaut [null], [a.Membre] sinon. Ainsi [texte?.Length] ne provoque pas d'erreur lorsque [texte] vaut [null] ; le résultat est de type [int?].

Le programme [bases-14.cs] réunit ces différents opérateurs :

// autres opérateurs
// expressions booléennes
double x = 3.5;
bool valide = x > 2 && x < 4;
Console.WriteLine($"valide={valide}, !valide={!valide}, valide || x > 10 : {valide || x > 10}");

// opérateurs combinés
int a = 10;
a += 5;
Console.Write($"a += 5 : {a}");
a *= 2;
Console.Write($", a *= 2 : {a}");
a /= 4;
Console.Write($", a /= 4 : {a}");
a %= 4;
Console.Write($", a %= 4 : {a}");
a <<= 2;
Console.WriteLine($", a <<= 2 : {a}");

// incrémentation et décrémentation
int i = 5;
int j = i++;    // j reçoit l'ancienne valeur de i, puis i est incrémenté
int k = ++i;    // i est incrémenté, puis k reçoit la nouvelle valeur de i
Console.WriteLine($"i={i}, j={j}, k={k}");
i--;
Console.WriteLine($"i={i}");

// opérateur ternaire
int n = 5;
int p = n > 4 ? n + 1 : n - 1;
string parite = n % 2 == 0 ? "pair" : "impair";
Console.WriteLine($"p={p}, {n} est {parite}");

// opérateur ?? : valeur de remplacement si l'opérande gauche est null
string? saisie = null;
string nom = saisie ?? "inconnu";
int? age = null;
int ageAffiche = age ?? -1;
Console.WriteLine($"nom={nom}, ageAffiche={ageAffiche}");
// opérateur ??= : affectation seulement si la variable vaut null
string? titre = null;
titre ??= "Sans titre";
titre ??= "Autre titre";
Console.WriteLine($"titre={titre}");
// opérateur ?. : accès à un membre seulement si l'objet n'est pas null
string? texte = null;
int? longueur = texte?.Length;
Console.WriteLine($"longueur={longueur?.ToString() ?? "null"}");
texte = "bonjour";
Console.WriteLine($"longueur={texte?.Length}");

Résultats :

> dotnet run bases-14.cs
valide=True, !valide=False, valide || x > 10 : True
a += 5 : 15, a *= 2 : 30, a /= 4 : 7, a %= 4 : 3, a <<= 2 : 12
i=7, j=5, k=7
i=6
p=6, 5 est impair
nom=inconnu, ageAffiche=-1
titre=Sans titre
longueur=null
longueur=7

Commentaires :

  • ligne 4 : une expression booléenne ;
  • lignes 9-17 : les opérateurs combinés : 10+5=15, 15*2=30, 30/4=7 (division entière), 7%4=3, 3<<2=12 ;
  • ligne 22 : [j] reçoit 5, puis [i] passe à 6 ;
  • ligne 23 : [i] passe à 7, puis [k] reçoit 7 ;
  • lignes 30-31 : deux opérateurs ternaires ;
  • ligne 36 : [saisie] vaut [null] : [nom] reçoit "inconnu". Le type de [nom] est [string] et non [string?] ;
  • ligne 38 : l'opérateur ?? permet de passer d'un [int?] à un [int] sans risque d'exception ;
  • lignes 42-43 : la première affectation a lieu car [titre] vaut [null] ; la seconde n'a pas lieu ;
  • ligne 47 : [texte] vaut [null], donc [longueur] aussi ;
  • ligne 48 : les opérateurs se combinent : si [longueur] vaut [null], on affiche la chaîne "null".

3.4.5.11. Priorité générale des opérateurs

Le tableau suivant donne la priorité des opérateurs, du plus prioritaire au moins prioritaire :

Opérateurs
Associativité
[x.y] [x?.y] [f(x)] [a[i]] [x++] [x--] [new] [typeof] [checked] [nameof]
gd
[+x] [-x] [!x] [~x] [++x] [--x] [^x] [(T)x] (changement de type)
dg
[x..y] (plage)
gd
[switch] (expression switch)
gd
[*] [/] [%]
gd
[+] [-]
gd
[<<] [>>] [>>>]
gd
[<] [<=] [>] [>=] [is] [as]
gd
[==] [!=]
gd
[&]
gd
[^]
gd
[|]
gd
[&&]
gd
[||]
gd
[??]
dg
[? :] (ternaire)
dg
[=] [+=] [-=] [??=] etc., [=>]
dg

gd indique qu'à priorité égale, c'est la priorité gauche-droite qui est observée : lorsque dans une expression on a des opérateurs de même priorité, c'est l'opérateur le plus à gauche qui est évalué en premier. dg indique une priorité droite-gauche. En cas de doute, on n'hésitera pas à mettre des parenthèses : elles rendent aussi l'expression plus lisible.

3.4.5.12. Les changements de type

Il est possible, dans une expression, de changer momentanément le codage d'une valeur. On appelle cela changer le type d'une donnée, ou en anglais « type casting ». La syntaxe du changement du type d'une valeur dans une expression est la suivante :

(type) valeur

La valeur prend alors le type indiqué. Cela entraîne un changement de codage de la valeur. On distingue :

  • les conversions implicites, faites automatiquement par le compilateur lorsqu'elles ne font pas perdre d'information : d'un [int] vers un [long], d'un [int] vers un [double], d'un [char] vers un [int], etc. La conversion d'un [long] vers un [double] est elle aussi implicite, bien qu'elle puisse perdre en précision au-delà de 2⁵³ ;
  • les conversions explicites, qui peuvent perdre de l'information et que le programmeur doit demander avec la notation (type) : d'un [double] vers un [int] (la partie décimale est perdue), d'un [long] vers un [int] (les bits de poids fort sont perdus), etc.

Par défaut, un dépassement de capacité dans un calcul entier ou une conversion entre entiers n'est pas signalé : le résultat est simplement faux. Le mot clé [checked] demande au contraire qu'un dépassement lance une exception [OverflowException]. Il s'applique à une expression, [checked(expression)], ou à un bloc d'instructions, [checked { ... }].

Le programme [bases-15.cs] est le suivant :

// les changements de type
int i = 3, j = 4;
float f1 = i / j;
float f2 = (float)i / j;
Console.WriteLine($"f1={f1}, f2={f2}");

// conversions implicites : sans perte d'information
long l = i;
double d = l;
Console.WriteLine($"l={l}, d={d}");

// conversions explicites (cast) : perte d'information possible
double reel = 3.99;
int tronque = (int)reel;
int arrondi = (int)Math.Round(reel);
Console.WriteLine($"(int)3.99={tronque}, (int)Math.Round(3.99)={arrondi}");
long grand = 3_000_000_000;
int deborde = (int)grand;
Console.WriteLine($"(int)3000000000={deborde}");

// caractères et entiers
char c = (char)65;
int code = 'é';
Console.WriteLine($"(char)65={c}, code de 'é'={code}");

// dépassement de capacité : silencieux par défaut
int max = int.MaxValue;
int suivant = max + 1;
Console.WriteLine($"int.MaxValue + 1 = {suivant}");
// checked : le dépassement provoque une exception
try
{
    int verifie = checked(max + 1);
    Console.WriteLine($"verifie={verifie}");
}
catch (OverflowException e)
{
    Console.WriteLine($"checked(max + 1) : {e.GetType().Name} - {e.Message}");
}
try
{
    checked
    {
        int conversion = (int)grand;
        Console.WriteLine($"conversion={conversion}");
    }
}
catch (OverflowException e)
{
    Console.WriteLine($"checked((int)grand) : {e.GetType().Name} - {e.Message}");
}

Résultats :

1
2
3
4
5
6
7
8
9
> dotnet run bases-15.cs
f1=0, f2=0,75
l=3, d=3
(int)3.99=3, (int)Math.Round(3.99)=4
(int)3000000000=-1294967296
(char)65=A, code de 'é'=233
int.MaxValue + 1 = -2147483648
checked(max + 1) : OverflowException - Arithmetic operation resulted in an overflow.
checked((int)grand) : OverflowException - Arithmetic operation resulted in an overflow.

Commentaires :

  • ligne 3 : [f1] aura la valeur 0. La division 3/4 est une division entière puisque les deux opérandes sont de type [int] ; son résultat 0 est ensuite converti en [float] ;
  • ligne 4 : [(float)i] est la valeur de [i] transformée en [float]. On a maintenant une division entre un réel de type [float] et un entier de type [int] : la valeur de [j] est elle aussi transformée en [float], puis la division des deux réels est faite. [f2] a la valeur 0,75. Dans l'opération [(float)i], [i] est une valeur codée de façon exacte sur 4 octets, [(float)i] est la même valeur codée de façon approchée en réel sur 4 octets. Ce transcodage n'a lieu que le temps du calcul : la variable [i] conserve toujours son type [int] ;
  • lignes 8-9 : des conversions implicites ;
  • ligne 14 : la conversion d'un réel en entier tronque la partie décimale ;
  • ligne 15 : pour arrondir, on utilise [Math.Round], qui rend un [double], puis on convertit ;
  • ligne 18 : 3 milliards ne tient pas dans un [int] : seuls les 32 bits de poids faible sont conservés et le résultat n'a plus de sens ;
  • lignes 22-23 : conversions entre caractères et codes Unicode ; la seconde est implicite ;
  • ligne 28 : le dépassement de capacité passe inaperçu : on obtient le plus petit [int] ;
  • ligne 33 : avec [checked], le dépassement lance une exception [OverflowException] ;
  • lignes 42-45 : un bloc [checked] : la conversion de [grand] en [int] lance elle aussi une exception.

Si le dépassement a lieu dans une expression formée uniquement de constantes, comme [int.MaxValue + 1], c'est le compilateur qui le détecte et refuse le programme.

3.5. Les instructions de contrôle du déroulement du programme

3.5.1. Arrêt

La méthode [Exit] de la classe [Environment] permet d'arrêter l'exécution d'un programme :

Environment.Exit(int status);

[Exit] provoque la fin du processus en cours et rend la main au processus appelant, en général l'interpréteur de commandes. La valeur de [status], appelée code de retour, peut être utilisée par celui-ci. Par convention, 0 signifie que le programme s'est bien déroulé et une autre valeur signale une erreur. Sous Windows, le code de retour est disponible dans la variable [%ERRORLEVEL%] de l'invite de commandes et dans la variable [$LASTEXITCODE] de PowerShell ; sous Linux et macOS, dans la variable [$?] du shell. Ainsi :

Environment.Exit(0);

arrêtera l'exécution du programme avec un code de retour égal à 0. Avec les instructions de haut niveau, une instruction [return] arrête aussi le programme (code de retour 0), sauf si elle se trouve dans une fonction.

Le programme [bases-16.cs] reçoit son code de retour en argument :

// arrêt du programme avec un code de retour
Console.WriteLine("Début du programme");
// le code de retour est passé en argument (voir la section sur les arguments du programme)
if (args.Length == 0 || !int.TryParse(args[0], out int code))
{
    Console.Error.WriteLine("Syntaxe : dotnet run bases-16.cs -- code_de_retour");
    Environment.Exit(1);
    return;
}
Console.WriteLine($"Arrêt avec le code de retour {code}");
Environment.Exit(code);
Console.WriteLine("Cette ligne n'est jamais exécutée");

Résultats dans un terminal bash :

> dotnet run bases-16.cs -- 3
Début du programme
Arrêt avec le code de retour 3
> echo $?
3
> dotnet run bases-16.cs
Début du programme
Syntaxe : dotnet run bases-16.cs -- code_de_retour
> echo $?
1

Commentaires :

  • ligne 4 : on vérifie qu'un argument a été passé et qu'il s'agit d'un entier. Les arguments du programme sont présentés plus loin ;
  • ligne 7 : si ce n'est pas le cas, on arrête le programme avec le code de retour 1 ;
  • ligne 8 : cette instruction ne sera jamais exécutée, mais le compilateur ne sait pas que [Environment.Exit] ne rend jamais la main. Sans elle, il considérerait que l'on peut arriver à la ligne suivante avec une variable [code] non initialisée, et refuserait le programme ;
  • ligne 11 : arrêt avec le code de retour passé en argument. La ligne qui suit n'est pas exécutée ;
  • lignes 4-9 : la commande [echo $?] affiche le code de retour du dernier programme exécuté : 3 puis 1.

3.5.2. Structure de choix simple

1
2
3
4
5
6
7
8
if (condition)
{
    actions_condition_vraie;
}
else
{
    actions_condition_fausse;
}

Notes :

  • la condition est entourée de parenthèses ;
  • chaque action est terminée par un point-virgule ;
  • les accolades ne sont pas suivies d'un point-virgule ;
  • les accolades ne sont nécessaires que s'il y a plus d'une action ; on les met cependant souvent pour une seule action, par lisibilité et pour éviter les erreurs lors de l'ajout d'une action ;
  • la clause [else] peut être absente ;
  • il n'y a pas de mot clé [then].

L'équivalent algorithmique de cette structure est la structure si .. alors .. sinon :

1
2
3
4
si condition
    alors actions_condition_vraie
    sinon actions_condition_fausse
finsi

Exemple :

if (x > 0) { nx = nx + 1; sx = sx + x; } else dx = dx - x;

On peut imbriquer les structures de choix. Se pose alors parfois le problème illustré par le programme [bases-17.cs] :

// structure de choix simple if
int n = 5;
// un else se rapporte toujours au if le plus proche
if (n > 1)
    if (n > 6)
        Console.WriteLine(">6");
    else
        Console.WriteLine("<=6");

// avec des accolades, on rattache le else au premier if
int n2 = 0;
if (n2 > 1)
{
    if (n2 > 6)
    {
        Console.WriteLine(">6");
    }
}
else
{
    Console.WriteLine("<=1");
}

// une suite de choix : else if
int note = 13;
string mention;
if (note < 10)
{
    mention = "ajourné";
}
else if (note < 12)
{
    mention = "passable";
}
else if (note < 14)
{
    mention = "assez bien";
}
else
{
    mention = "bien";
}
Console.WriteLine($"note={note}, mention={mention}");

Résultats :

1
2
3
4
> dotnet run bases-17.cs
<=6
<=1
note=13, mention=assez bien

Commentaires :

  • lignes 4-8 : le [else] de la ligne 7 se rapporte à quel [if] ? La règle est qu'un [else] se rapporte toujours au [if] le plus proche : [if (n > 6)], ligne 5. L'indentation du code, qui n'a aucun effet sur le compilateur, a été choisie pour le montrer ;
  • lignes 12-21 : ici, nous voulions mettre un [else] au [if (n2 > 1)] et pas de [else] au [if (n2 > 6)]. À cause de la règle précédente, nous sommes obligés de mettre des accolades au [if (n2 > 1) { ... }] ;
  • lignes 27-41 : une suite de choix exclusifs s'écrit avec des [else if] successifs. Les conditions sont testées dans l'ordre ; la première vraie détermine l'action exécutée.

3.5.3. Structure de cas

3.5.3.1. L'instruction switch

La syntaxe est la suivante :

switch (expression)
{
    case v1:
        actions1;
        break;
    case v2:
        actions2;
        break;
    ...
    default:
        actions_sinon;
        break;
}

Notes :

  • la valeur de l'expression de contrôle du [switch] peut être un entier, un caractère, une chaîne de caractères, une valeur d'énumération, etc. ;
  • l'expression de contrôle est entourée de parenthèses ;
  • la clause [default] peut être absente ;
  • les valeurs vi sont des valeurs possibles de l'expression. Si l'expression a pour valeur vi, les actions derrière la clause [case vi] sont exécutées ;
  • l'instruction [break] fait sortir de la structure de cas ;
  • chaque bloc d'instructions lié à une valeur vi doit se terminer par une instruction de branchement ([break], [return], [throw], ...), sinon le compilateur signale une erreur. Plusieurs clauses [case] peuvent en revanche se suivre sans instruction entre elles : elles partagent alors les mêmes actions.

Exemple en algorithmique :

selon la valeur de choix
    cas 0
        fin du module
    cas 1
        exécuter module M1
    cas 2
        exécuter module M2
    sinon
        erreur <- vrai
findescas

3.5.3.2. L'expression switch

Très souvent, une structure de cas ne sert qu'à choisir une valeur. C# 8 a introduit pour cela l'expression switch :

1
2
3
4
5
6
7
resultat = expression switch
{
    motif1 => valeur1,
    motif2 => valeur2,
    ...
    _ => valeur_sinon
};

L'expression est comparée successivement aux motifs ; la valeur associée au premier motif qui convient est le résultat. Le motif _ convient à toutes les valeurs : il joue le rôle de [default]. Un motif peut être :

  • une constante : [0], ["samedi"] ;
  • un motif relationnel (C# 9) : [< 0], [>= 18] ;
  • une combinaison de motifs avec [and], [or] et [not] (C# 9) : [> 0 and <= 10], ["samedi" or "dimanche"].

Ces mêmes motifs s'utilisent avec l'opérateur [is] : [jour is "samedi" or "dimanche"] est vrai si [jour] vaut "samedi" ou "dimanche". Nous en verrons d'autres formes dans les chapitres suivants.

Le programme [bases-18.cs] est le suivant :

// structure de cas : instruction switch et expression switch
int choix = 2;
bool erreur = false;
switch (choix)
{
    case 0:
        return;
    case 1:
        M1();
        break;
    case 2:
        M2();
        break;
    default:
        erreur = true;
        break;
}
Console.WriteLine($"erreur={erreur}");

// plusieurs valeurs pour un même cas, expression de type string
string jour = "samedi";
switch (jour)
{
    case "samedi":
    case "dimanche":
        Console.WriteLine($"{jour} : week-end");
        break;
    default:
        Console.WriteLine($"{jour} : semaine");
        break;
}

// expression switch (C# 8) : elle rend une valeur
string typeJour = jour switch
{
    "samedi" or "dimanche" => "week-end",
    "lundi" or "mardi" or "mercredi" or "jeudi" or "vendredi" => "semaine",
    _ => "jour inconnu"
};
Console.WriteLine($"{jour} : {typeJour}");

// motifs relationnels (<, <=, >, >=) et logiques (and, or, not) (C# 9)
Console.WriteLine($"-5 : {Classer(-5)}");
Console.WriteLine($"0 : {Classer(0)}");
Console.WriteLine($"7 : {Classer(7)}");
Console.WriteLine($"42 : {Classer(42)}");
Console.WriteLine($"1000 : {Classer(1000)}");
// le motif not
bool ouvrable = jour is not ("samedi" or "dimanche");
Console.WriteLine($"{jour} ouvrable : {ouvrable}");

// fonctions locales utilisées ci-dessus
static void M1()
{
    Console.WriteLine("M1");
}

static void M2()
{
    Console.WriteLine("M2");
}

static string Classer(int n) => n switch
{
    < 0 => "négatif",
    0 => "nul",
    > 0 and <= 10 => "petit",
    > 10 and < 100 => "moyen",
    _ => "grand"
};

Résultats :

> dotnet run bases-18.cs
M2
erreur=False
samedi : week-end
samedi : week-end
-5 : négatif
0 : nul
7 : petit
42 : moyen
1000 : grand
samedi ouvrable : False

Commentaires :

  • lignes 4-15 : la traduction en C# de l'algorithme ci-dessus ;
  • ligne 7 : dans les instructions de haut niveau, [return] termine le programme ;
  • lignes 24-25 : deux valeurs pour les mêmes actions ;
  • lignes 34-38 : une expression switch sur une chaîne. Les motifs sont combinés avec [or] ;
  • ligne 49 : le motif [not] avec l'opérateur [is] ;
  • lignes 53-60 : deux fonctions locales ;
  • lignes 63-69 : une fonction locale dont le corps est une expression switch. La notation => remplace le corps entre accolades et l'instruction [return] : on parle de corps d'expression. Les motifs relationnels classent l'entier [n].

3.5.4. Structures de répétition

3.5.4.1. Nombre de répétitions connu

Structure for

La syntaxe est la suivante :

1
2
3
4
for (i = id; i <= if; i = i + ip)
{
    actions;
}

Notes :

  • les 3 arguments du [for] sont à l'intérieur d'une parenthèse et séparés par des points-virgules ;
  • chaque action du [for] est terminée par un point-virgule ;
  • l'accolade n'est nécessaire que s'il y a plus d'une action ;
  • l'accolade n'est pas suivie d'un point-virgule.

L'équivalent algorithmique est la structure pour :

1
2
3
pour i variant de id à if avec un pas de ip
    actions
finpour

qu'on peut traduire par une structure tantque :

1
2
3
4
5
i <- id
tantque i <= if
    actions
    i <- i + ip
fintantque

Structure foreach

La syntaxe est la suivante :

1
2
3
4
foreach (Type variable in collection)
{
    instructions;
}

Notes :

  • [collection] est une collection d'objets énumérable. La collection énumérable que nous connaissons déjà est le tableau ; nous en verrons d'autres ([List<T>], [Dictionary<K, V>], ...) au chapitre 5 ;
  • [Type] est le type des objets de la collection. Pour un tableau, ce serait le type des éléments du tableau. On peut écrire [var] à la place ;
  • [variable] est une variable locale à la boucle qui va prendre successivement pour valeur toutes les valeurs de la collection. Elle ne peut pas être modifiée dans la boucle.

3.5.4.2. Nombre de répétitions inconnu

Structure tantque (while)

1
2
3
4
while (condition)
{
    actions;
}

On boucle tant que la condition est vérifiée. La boucle peut ne jamais être exécutée. Les notes sont les mêmes que pour le [for]. La structure algorithmique correspondante est la structure tantque :

1
2
3
tantque condition
    actions
fintantque

Structure répéter jusqu'à (do while)

La syntaxe est la suivante :

1
2
3
4
do
{
    instructions;
} while (condition);

On boucle jusqu'à ce que la condition devienne fausse. Ici, la boucle est faite au moins une fois. La structure algorithmique correspondante est la structure répéter ... jusqu'à :

1
2
3
répéter
    actions
jusqu'à non condition

Structure pour générale (for)

La syntaxe est la suivante :

1
2
3
4
for (instructions_depart; condition; instructions_fin_boucle)
{
    instructions;
}

On boucle tant que la condition est vraie (évaluée avant chaque tour de boucle). Les [instructions_depart] sont effectuées avant d'entrer dans la boucle pour la première fois. Les [instructions_fin_boucle] sont exécutées après chaque tour de boucle. Les différentes instructions de [instructions_depart] et de [instructions_fin_boucle] sont séparées par des virgules. La structure algorithmique correspondante est la suivante :

1
2
3
4
5
instructions_depart
tantque condition
    actions
    instructions_fin_boucle
fintantque

3.5.4.3. Instructions de gestion de boucle

Instruction
Rôle
[break]
fait sortir de la boucle [for], [foreach], [while], [do ... while]
[continue]
fait passer à l'itération suivante des boucles [for], [foreach], [while], [do ... while]

3.5.4.4. Exemples

Le programme [bases-19.cs] est le suivant :

// les structures de répétition
// quatre façons de calculer la somme des 10 premiers entiers
int i, somme, n = 10;
for (i = 1, somme = 0; i <= n; i = i + 1)
{
    somme = somme + i;
}
Console.WriteLine($"for (1) : somme={somme}");

for (i = 1, somme = 0; i <= n; somme = somme + i, i = i + 1)
{
}
Console.WriteLine($"for (2) : somme={somme}");

i = 1;
somme = 0;
while (i <= n)
{
    somme += i;
    i++;
}
Console.WriteLine($"while : somme={somme}");

i = 1;
somme = 0;
do
{
    somme += i++;
} while (i <= n);
Console.WriteLine($"do while : somme={somme}");

// la variable de boucle peut être déclarée dans le for : elle n'existe que dans la boucle
int produit = 1;
for (int k = 1; k <= 5; k++)
{
    produit *= k;
}
Console.WriteLine($"5! = {produit}");

// foreach : parcours de tous les éléments d'une collection
string[] amis = ["paul", "hélène", "jacques", "sylvie"];
foreach (string nom in amis)
{
    Console.WriteLine(nom);
}

// break et continue
for (int k = 1; k <= 10; k++)
{
    // les nombres pairs sont ignorés
    if (k % 2 == 0)
    {
        continue;
    }
    // on s'arrête après 7
    if (k > 7)
    {
        break;
    }
    Console.Write($"{k} ");
}
Console.WriteLine();

// boucle infinie dont on sort par break : le premier multiple de 7 supérieur à 50
int multiple = 50;
while (true)
{
    multiple++;
    if (multiple % 7 == 0)
    {
        break;
    }
}
Console.WriteLine($"premier multiple de 7 supérieur à 50 : {multiple}");

Résultats :

> dotnet run bases-19.cs
for (1) : somme=55
for (2) : somme=55
while : somme=55
do while : somme=55
5! = 120
paul
hélène
jacques
sylvie
1 3 5 7 
premier multiple de 7 supérieur à 50 : 56

Commentaires :

  • lignes 3-30 : quatre façons de calculer la somme des 10 premiers nombres entiers ;
  • lignes 4-8 : un [for] classique ;
  • lignes 10-13 : tout le travail est fait dans les instructions de fin de boucle : le corps de la boucle est vide. Le cours de 2008 écrivait [for (...) ;] : C# accepte cette instruction vide mais le compilateur émet un avertissement (« possible instruction vide erronée »), car un point-virgule placé par erreur derrière un [for] est une faute classique. Un bloc vide { } exprime la même chose sans ambiguïté ;
  • ligne 28 : [i++] rend la valeur de [i] avant son incrémentation : on ajoute [i] à [somme], puis on incrémente [i] ;
  • ligne 34 : la forme la plus courante du [for] : la variable de boucle est déclarée dans le [for] et n'existe que dans la boucle ;
  • lignes 41-42 : la boucle [foreach] parcourt les éléments du tableau [amis] ;
  • lignes 51-58 : [continue] passe à l'itération suivante pour les nombres pairs, [break] arrête la boucle dès que [k] dépasse 7 ;
  • lignes 66-74 : une boucle infinie [while (true)] dont on sort par [break]. Cette forme est commode lorsque la condition d'arrêt se calcule au milieu de la boucle. Nous l'utiliserons pour les saisies au clavier.

3.6. La gestion des exceptions

De nombreuses méthodes .NET sont susceptibles de générer des exceptions, c'est-à-dire des erreurs. Lorsqu'une méthode est susceptible de générer une exception, le programmeur devrait la gérer dans le but d'obtenir des programmes plus résistants aux erreurs : il faut toujours éviter l'arrêt brutal d'une application.

3.6.1. La structure try / catch / finally

La gestion d'une exception se fait selon le schéma suivant :

1
2
3
4
5
6
7
8
9
try
{
    code susceptible de générer une exception
}
catch (Exception e)
{
    traiter l'exception e
}
instruction suivante

Si le code de la clause [try] ne génère pas d'exception, on passe à l'instruction suivante ; sinon, on passe dans le corps de la clause [catch], puis à l'instruction suivante. Notons les points suivants :

  • [e] est un objet de type [Exception] ou d'un type dérivé. Il existe de nombreux types d'exceptions : [IndexOutOfRangeException], [FormatException], [OverflowException], [ArgumentException], etc. En écrivant [catch (Exception e)], on indique qu'on veut gérer tous les types d'exceptions. Si le code de la clause [try] est susceptible de générer plusieurs types d'exceptions, on peut vouloir être plus précis en gérant l'exception avec plusieurs clauses [catch] :
try
{
    code susceptible de générer les exceptions
}
catch (IndexOutOfRangeException e1)
{
    traiter l'exception e1
}
catch (FormatException e2)
{
    traiter l'exception e2
}
instruction suivante

Les clauses [catch] sont examinées dans l'ordre ; la première dont le type correspond à l'exception est exécutée. Une clause [catch (Exception e)] doit donc être placée en dernier ;

  • on peut ajouter aux clauses [try / catch] une clause [finally] :
try
{
    code susceptible de générer une exception
}
catch (Exception e)
{
    traiter l'exception e
}
finally
{
    code exécuté après try ou catch
}
instruction suivante

Qu'il y ait exception ou pas, le code de la clause [finally] est toujours exécuté. On y place par exemple la fermeture d'un fichier ;

  • dans la clause [catch], on peut ne pas vouloir utiliser l'objet [Exception] disponible. Au lieu d'écrire [catch (Exception e) { ... }], on écrit alors [catch (Exception) { ... }] ou plus simplement [catch { ... }] ;
  • la classe [Exception] a une propriété [Message] qui détaille l'erreur qui s'est produite. Si on veut l'afficher, on écrira :
1
2
3
4
5
catch (Exception ex)
{
    Console.WriteLine($"L'erreur suivante s'est produite : {ex.Message}");
    ...
}
  • la classe [Exception] a une méthode [ToString] qui rend une chaîne de caractères indiquant le type de l'exception, la valeur de la propriété [Message] et la pile des appels, c'est-à-dire la liste des méthodes en cours d'exécution au moment de l'erreur, avec le nom du fichier source et le numéro de ligne. C'est l'information la plus utile pour le développeur, mais elle n'est pas destinée à l'utilisateur. Dans ce cours, nous afficherons le plus souvent le nom du type de l'exception, [ex.GetType().Name], et son message.

3.6.2. Un premier exemple

Le programme [bases-20.cs] montre une exception générée par l'utilisation d'un élément de tableau inexistant :

// une exception due à un indice de tableau incorrect
int[] tab = [0, 1, 2, 3];
// affichage du tableau avec un for
for (int i = 0; i < tab.Length; i++)
{
    Console.WriteLine($"tab[{i}]={tab[i]}");
}
// affichage du tableau avec un foreach
foreach (int elmt in tab)
{
    Console.WriteLine(elmt);
}
// génération d'une exception
try
{
    tab[100] = 6;
}
catch (Exception e)
{
    Console.Error.WriteLine($"L'erreur suivante s'est produite : {e.GetType().FullName} - {e.Message}");
    return;
}
finally
{
    Console.WriteLine("finally ...");
}
Console.WriteLine("Cette ligne n'est pas exécutée");

Résultats :

> dotnet run bases-20.cs
tab[0]=0
tab[1]=1
tab[2]=2
tab[3]=3
0
1
2
3
L'erreur suivante s'est produite : System.IndexOutOfRangeException - Index was outside the bounds of the array.
finally ...

Commentaires :

  • ligne 16 : cette instruction génère une exception parce que le tableau [tab] n'a pas d'élément n° 100 ;
  • ligne 20 : l'exception est de type [System.IndexOutOfRangeException] (ligne 10 des résultats) ;
  • ligne 21 : on quitte le programme ;
  • lignes 23-25 : la clause [finally] a été exécutée (ligne 11 des résultats), alors même que la clause [catch] contenait une instruction [return]. On retiendra que la clause [finally] est toujours exécutée ;
  • ligne 27 : à cause du [return], cette ligne n'est pas exécutée.

3.6.3. Une saisie contrôlée

Voici un autre exemple, [bases-21.cs], où l'on gère l'exception provoquée par la conversion en entier d'une chaîne qui ne représente pas un nombre entier :

// saisie d'un âge avec gestion de l'exception de conversion
// on demande le nom
Console.Write("Nom : ");
string nom = Console.ReadLine() ?? "";
// on demande l'âge
int age = 0;
bool ageOK = false;
while (!ageOK)
{
    // question
    Console.Write("âge : ");
    // lecture-vérification réponse
    try
    {
        age = int.Parse(Console.ReadLine() ?? "");
        ageOK = age >= 1;
    }
    catch (FormatException)
    {
    }
    if (!ageOK)
    {
        Console.WriteLine("Âge incorrect, recommencez...");
    }
}
// affichage final
Console.WriteLine($"Vous vous appelez {nom} et vous avez {age} an(s)");

Résultats :

1
2
3
4
> dotnet run bases-21.cs
Nom : dupont
âge : 23
Vous vous appelez dupont et vous avez 23 an(s)
1
2
3
4
5
6
7
8
> dotnet run bases-21.cs
Nom : durand
âge : x
Âge incorrect, recommencez...
âge : -4
Âge incorrect, recommencez...
âge : 12
Vous vous appelez durand et vous avez 12 an(s)

Commentaires :

  • ligne 4 : si le flux d'entrée est fermé, [ReadLine] rend [null] : on le remplace par une chaîne vide. La variable [nom] peut ainsi être de type [string] ;
  • lignes 8-23 : la boucle de saisie de l'âge ;
  • ligne 15 : la ligne tapée au clavier est transformée en nombre entier par la méthode [int.Parse]. Cette méthode lance une exception si la conversion n'est pas possible. C'est pourquoi l'opération a été placée dans un [try / catch] ;
  • ligne 16 : si on arrive à cette ligne, c'est que la conversion a réussi. On vérifie cependant que l'entier obtenu est bien supérieur ou égal à 1 ;
  • ligne 18 : si une exception [FormatException] est lancée, on va dans le [catch] où rien n'est fait. Ainsi, le booléen [ageOK], initialisé à [false], reste à [false]. Le cours de 2008 écrivait [catch { }], qui intercepte toutes les exceptions : c'est déconseillé, car on masque alors aussi les erreurs que l'on n'a pas prévues ;
  • lignes 21-23 : un message d'erreur est émis si l'âge est incorrect.

Ce programme a une faiblesse : un âge comme 99999999999 provoque une exception [OverflowException], non interceptée, qui arrête le programme. On pourrait ajouter une clause [catch] ; il est plus simple d'utiliser [int.TryParse], comme le fera l'application exemple ci-dessous. On réservera les exceptions aux situations réellement exceptionnelles.

3.6.4. Plusieurs clauses catch, filtres et lancement d'exceptions

Le programme peut lui-même lancer une exception avec l'instruction [throw] :

throw new ArgumentException("message d'erreur");

L'exécution de la méthode en cours s'arrête et l'exception remonte vers les méthodes appelantes jusqu'à ce qu'une clause [catch] la gère ; si aucune ne le fait, le programme s'arrête en affichant l'exception. Dans une clause [catch], l'instruction [throw;] (sans paramètre) relance l'exception en cours, par exemple après l'avoir journalisée.

Depuis C# 6, une clause [catch] peut être suivie d'un filtre [when (condition)] : la clause n'est retenue que si la condition est vraie. Depuis C# 7, [throw] peut aussi être utilisé comme une expression, à droite de l'opérateur ?? ou dans un opérateur ternaire.

Le programme [bases-22.cs] illustre ces possibilités :

// plusieurs clauses catch, filtres when, lancement d'exceptions
string[] saisies = ["12", "abc", "", "99999999999", "-3", "150"];
foreach (string saisie in saisies)
{
    try
    {
        int age = LireAge(saisie);
        Console.WriteLine($"[{saisie}] : âge={age}");
    }
    // filtre when : la clause n'est retenue que si la condition est vraie
    catch (FormatException) when (saisie.Length == 0)
    {
        Console.WriteLine($"[{saisie}] : saisie vide");
    }
    catch (FormatException e)
    {
        Console.WriteLine($"[{saisie}] : {e.GetType().Name} - {e.Message}");
    }
    catch (OverflowException e)
    {
        Console.WriteLine($"[{saisie}] : {e.GetType().Name} - {e.Message}");
    }
    catch (ArgumentException e)
    {
        Console.WriteLine($"[{saisie}] : {e.GetType().Name} - {e.Message}");
    }
}

// expression throw (C# 7) : à droite de ?? ou dans un opérateur ternaire
string?[] noms = ["dupont", null];
foreach (string? nom in noms)
{
    try
    {
        string valide = nom ?? throw new ArgumentNullException(nameof(nom), "le nom est obligatoire");
        Console.WriteLine($"nom={valide}");
    }
    catch (ArgumentNullException e)
    {
        Console.WriteLine($"{e.GetType().Name} - {e.Message}");
    }
}

// une fonction locale qui lance des exceptions
static int LireAge(string texte)
{
    // int.Parse lance FormatException ou OverflowException
    int age = int.Parse(texte);
    // nous lançons nous-mêmes une exception si l'âge est hors limites
    if (age < 0 || age > 130)
    {
        throw new ArgumentException($"âge hors limites [0,130] : {age}");
    }
    return age;
}

Résultats :

1
2
3
4
5
6
7
8
9
> dotnet run bases-22.cs
[12] : âge=12
[abc] : FormatException - The input string 'abc' was not in a correct format.
[] : saisie vide
[99999999999] : OverflowException - Value was either too large or too small for an Int32.
[-3] : ArgumentException - âge hors limites [0,130] : -3
[150] : ArgumentException - âge hors limites [0,130] : 150
nom=dupont
ArgumentNullException - le nom est obligatoire (Parameter 'nom')

Commentaires :

  • ligne 2 : des saisies simulées, valides ou non ;
  • ligne 7 : la fonction locale [LireAge] peut lancer plusieurs types d'exceptions ;
  • ligne 11 : cette clause ne gère une [FormatException] que si la saisie est vide ;
  • ligne 15 : les autres [FormatException] ;
  • ligne 19 : un nombre trop grand pour un [int] ;
  • ligne 23 : l'exception lancée par [LireAge] elle-même ;
  • ligne 35 : une expression [throw] : si [nom] vaut [null], une exception [ArgumentNullException] est lancée. L'opérateur [nameof] (C# 6) donne le nom d'une variable sous forme de chaîne ; [ArgumentNullException] l'ajoute à son message (ligne 9 des résultats) ;
  • ligne 52 : la fonction [LireAge] lance une exception si l'âge est hors limites.

3.7. Application exemple - V1

On se propose d'écrire un programme permettant de calculer l'impôt d'un contribuable. On se place dans le cas simplifié d'un contribuable n'ayant que son seul salaire à déclarer. Les chiffres sont ceux du barème 2004 sur les revenus 2003, conservés tels quels : c'est un exercice de programmation, pas un calcul de l'impôt actuel. Cette application nous servira de fil rouge : nous la reprendrons dans les chapitres suivants avec des classes, des fichiers, une base de données, puis sur le réseau.

  • on calcule le nombre de parts du salarié : nbParts = nbEnfants/2 + 1 s'il n'est pas marié, nbEnfants/2 + 2 s'il est marié, où nbEnfants est son nombre d'enfants ;
  • s'il a au moins trois enfants, il a une demi-part de plus ;
  • on calcule son revenu imposable R = 0,72 * S, où S est son salaire annuel ;
  • on calcule son quotient familial QF = R / nbParts ;
  • on calcule son impôt I à l'aide du tableau suivant :
limite
coeffR
coeffN
4962
0
0
8382
0,068
291,09
14753
0,191
1322,92
23888
0,283
2668,39
38868
0,374
4846,98
47932
0,426
6883,66
0
0,481
9505,54

Chaque ligne a 3 champs. Pour calculer l'impôt I, on recherche la première ligne telle que QF <= limite. Par exemple, si QF = 5000, on trouvera la ligne :

8382    0,068    291,09

L'impôt I est alors égal à 0,068 * R - 291,09 * nbParts, tronqué à l'euro. Si QF est tel que la relation QF <= limite n'est jamais vérifiée, ce sont les coefficients de la dernière ligne qui sont utilisés : I = 0,481 * R - 9505,54 * nbParts. La limite 0 de la dernière ligne n'est donc jamais utilisée.

Le programme C# correspondant [impots-v1.cs] est le suivant :

// calcul de l'impôt - version 1
// barème 2004 (revenus 2003) : c'est un exercice, pas le barème en vigueur

// tableaux de données nécessaires au calcul de l'impôt
decimal[] limites = [4962M, 8382M, 14753M, 23888M, 38868M, 47932M, 0M];
decimal[] coeffR = [0M, 0.068M, 0.191M, 0.283M, 0.374M, 0.426M, 0.481M];
decimal[] coeffN = [0M, 291.09M, 1322.92M, 2668.39M, 4846.98M, 6883.66M, 9505.54M];

// statut marital
string reponse;
while (true)
{
    Console.Write("Êtes-vous marié(e) (O/N) ? ");
    reponse = (Console.ReadLine() ?? "").Trim().ToLower();
    if (reponse is "o" or "n")
    {
        break;
    }
    Console.Error.WriteLine("Réponse incorrecte. Recommencez");
}
bool marie = reponse == "o";

// nombre d'enfants
int nbEnfants;
while (true)
{
    Console.Write("Nombre d'enfants : ");
    if (int.TryParse(Console.ReadLine(), out nbEnfants) && nbEnfants >= 0)
    {
        break;
    }
    Console.Error.WriteLine("Réponse incorrecte. Recommencez");
}

// salaire annuel
int salaire;
while (true)
{
    Console.Write("Salaire annuel : ");
    if (int.TryParse(Console.ReadLine(), out salaire) && salaire >= 0)
    {
        break;
    }
    Console.Error.WriteLine("Réponse incorrecte. Recommencez");
}

// nombre de parts
decimal nbParts = marie ? nbEnfants / 2M + 2 : nbEnfants / 2M + 1;
if (nbEnfants >= 3)
{
    nbParts += 0.5M;
}

// revenu imposable
decimal revenu = 0.72M * salaire;

// quotient familial
decimal quotient = revenu / nbParts;

// recherche de la tranche d'impôt : la première telle que quotient <= limite,
// la dernière si aucune ne convient
int i = 0;
while (i < limites.Length - 1 && quotient > limites[i])
{
    i++;
}

// l'impôt (tronqué à l'euro)
int impot = (int)(coeffR[i] * revenu - coeffN[i] * nbParts);

// affichage du résultat
Console.WriteLine($"Impôt à payer : {impot} euros");

Commentaires :

  • lignes 5-7 : les valeurs numériques sont suffixées par M pour être de type [decimal]. Le type [decimal] code exactement les nombres décimaux comme 0,068 ; c'est le type des calculs financiers. Avec un [double], 0,068 ne serait qu'une approximation binaire ;
  • lignes 10-21 : la saisie du statut marital, dans une boucle [while (true)] dont on sort par [break] lorsque la réponse est correcte ;
  • ligne 14 : [Console.ReadLine()] rend la chaîne C1 tapée au clavier, ou [null] (remplacé par "") ; [C1.Trim()] enlève les espaces de début et de fin de C1 et rend une chaîne C2 ; [C2.ToLower()] rend la chaîne C2 transformée en minuscules. On accepte ainsi O et o ;
  • ligne 15 : un motif combiné avec [or] ;
  • ligne 19 : les messages d'erreur sont écrits sur le flux [Error] ;
  • ligne 21 : le booléen [marie] reçoit la valeur [true] ou [false] de la relation [reponse == "o"] ;
  • ligne 28 : [TryParse] rend [false] si la saisie n'est pas un entier ; la condition [nbEnfants >= 0] n'est alors pas évaluée (opérateur && court-circuité). [TryParse] accepte une chaîne [null] : il rend alors [false]. Contrairement à la version de 2008, aucune exception n'est utilisée ;
  • ligne 40 : même chose pour le salaire ;
  • ligne 48 : on ne peut pas écrire simplement nbEnfants / 2 : si nbEnfants vaut 3, on aurait 3/2, une division entière qui donnerait 1 et non 1,5. Le diviseur 2M étant de type [decimal], la division est faite en [decimal]. Le cours de 2008 écrivait [(decimal)nbEnfants / 2], ce qui revient au même ;
  • lignes 62-65 : la recherche de la tranche. On s'arrête à la première limite supérieure ou égale au quotient familial, et au plus tard sur la dernière tranche. La version de 2008 écrivait d'abord QF dans la dernière case du tableau des limites pour être sûre de s'arrêter : cela modifiait les données du barème ; la condition [i < limites.Length - 1] évite cet effet de bord ;
  • ligne 69 : l'impôt est tronqué à l'euro par la conversion en [int].

Voici des exemples d'exécution :

1
2
3
4
5
> dotnet run impots-v1.cs
Êtes-vous marié(e) (O/N) ? o
Nombre d'enfants : 2
Salaire annuel : 60000
Impôt à payer : 4282 euros
> dotnet run impots-v1.cs
Êtes-vous marié(e) (O/N) ? oui
Réponse incorrecte. Recommencez
Êtes-vous marié(e) (O/N) ? o
Nombre d'enfants : trois
Réponse incorrecte. Recommencez
Nombre d'enfants : 3
Salaire annuel : 60000 euros
Réponse incorrecte. Recommencez
Salaire annuel : 60000
Impôt à payer : 2959 euros
1
2
3
4
5
> dotnet run impots-v1.cs
Êtes-vous marié(e) (O/N) ? n
Nombre d'enfants : 0
Salaire annuel : 60000
Impôt à payer : 11519 euros

On retrouve les résultats du cours de 2008 : 4282 euros pour un contribuable marié avec 2 enfants et 2959 euros avec 3 enfants, pour un salaire annuel de 60000 euros. Un célibataire sans enfant ayant le même salaire paie 11519 euros. Dans la deuxième exécution, la saisie « 60000 euros » est refusée : [int.TryParse] n'accepte que les chaînes représentant exactement un entier (des espaces au début et à la fin sont tolérés).

3.8. Arguments du programme principal

La fonction principale [Main] peut admettre comme paramètre un tableau de chaînes [string[]]. Ce tableau contient les arguments de la ligne de commande utilisée pour lancer l'application. Ainsi, si on lance le programme P avec la commande :

P arg0 arg1 ... argn

et si la fonction [Main] est déclarée comme suit :

static void Main(string[] args)

on aura args[0]="arg0", args[1]="arg1", etc. Avec les instructions de haut niveau, il n'y a pas de méthode [Main] écrite par le programmeur, mais le tableau [args] est disponible de la même façon : c'est le paramètre de la méthode [Main] générée par le compilateur.

Avec [dotnet run], les arguments destinés au programme se placent après deux tirets [--], qui les séparent des options de la commande [dotnet run] elle-même :

dotnet run prog.cs -- a0 a1 a2

Le cours de 2008 indiquait les arguments dans les propriétés du projet Visual Studio ; la ligne de commande suffit aujourd'hui. Voici un exemple, [bases-23.cs] :

1
2
3
4
5
6
7
// les arguments de la ligne de commande
// avec les instructions de haut niveau, le tableau args est prédéfini
Console.WriteLine($"Il y a {args.Length} argument(s)");
for (int i = 0; i < args.Length; i++)
{
    Console.WriteLine($"args[{i}]={args[i]}");
}

Résultats :

1
2
3
4
5
6
> dotnet run bases-23.cs -- a0 a1 a2 a3
Il y a 4 argument(s)
args[0]=a0
args[1]=a1
args[2]=a2
args[3]=a3

Un argument qui contient des espaces doit être entouré de guillemets :

1
2
3
4
> dotnet run bases-23.cs -- "jean dupont" 23
Il y a 2 argument(s)
args[0]=jean dupont
args[1]=23

Le même programme écrit avec une méthode [Main] classique [bases-24.cs] :

// les arguments de la ligne de commande avec une méthode Main classique
namespace Bases;

public static class Program
{
    public static void Main(string[] args)
    {
        Console.WriteLine($"Il y a {args.Length} argument(s)");
        for (int i = 0; i < args.Length; i++)
        {
            Console.WriteLine($"args[{i}]={args[i]}");
        }
    }
}

Résultats :

1
2
3
4
5
6
> dotnet run bases-24.cs -- a0 a1 a2 a3
Il y a 4 argument(s)
args[0]=a0
args[1]=a1
args[2]=a2
args[3]=a3

Commentaires :

  • ligne 2 : un espace de noms de portée fichier (C# 10) ;
  • ligne 4 : une classe statique ne contient que des membres statiques ;
  • ligne 6 : le point d'entrée du programme. Comme ce fichier ne contient pas d'instructions de haut niveau, le compilateur cherche une méthode statique nommée [Main] ;
  • ligne 8 : le reste est identique à [bases-23.cs].

La signature [static void Main()] est valide si la fonction [Main] n'attend pas de paramètres. [Main] peut aussi rendre un [int], qui est alors le code de retour du programme : [static int Main(string[] args)].

3.9. Les énumérations

Une énumération est un type de données dont le domaine de valeurs est un ensemble de constantes entières nommées. Considérons un programme qui a à gérer des mentions à un examen. Il y en aurait cinq : Passable, AssezBien, Bien, TresBien, Excellent. On peut alors définir une énumération pour ces cinq constantes :

enum Mentions { Passable, AssezBien, Bien, TresBien, Excellent }

De façon interne, ces cinq constantes sont codées par des entiers consécutifs commençant par 0 pour la première constante, 1 pour la suivante, etc. On peut aussi fixer ces valeurs : [enum Mentions { Passable = 10, AssezBien = 12, ... }]. Une variable peut être déclarée comme prenant ses valeurs dans l'énumération :

Mentions maMention = Mentions.Passable;

On peut comparer une variable aux différentes valeurs possibles de l'énumération :

1
2
3
4
if (maMention == Mentions.Passable)
{
    Console.WriteLine("Peut mieux faire");
}

De la même façon que le type simple [int] est équivalent à la structure [System.Int32], toute énumération dérive de la classe [System.Enum]. Celle-ci a des méthodes statiques qui permettent d'obtenir les valeurs et les noms d'un type énuméré :

  • [Enum.GetValues<Mentions>()] (.NET 5) rend le tableau des valeurs de l'énumération [Mentions] ;
  • [Enum.GetNames<Mentions>()] rend le tableau de leurs noms ;
  • [Enum.TryParse(chaine, out Mentions m)] convertit une chaîne en valeur de l'énumération.

Le cours de 2008 écrivait [Enum.GetValues(typeof(Mentions))] : cette forme, toujours valide, rend un tableau non typé qu'il faut ensuite convertir. La forme générique [GetValues<Mentions>()] rend directement un tableau [Mentions[]].

Le programme [bases-25.cs] met en lumière ce qui vient d'être écrit :

// les énumérations
// une variable qui prend ses valeurs dans l'énumération Mentions
Mentions maMention = Mentions.Passable;
// affichage de la valeur de la variable
Console.WriteLine($"mention={maMention}");
// test avec une valeur de l'énumération
if (maMention == Mentions.Passable)
{
    Console.WriteLine("Peut mieux faire");
}
// liste des mentions : nom et valeur entière
foreach (Mentions m in Enum.GetValues<Mentions>())
{
    Console.WriteLine($"{m} = {(int)m}");
}
// liste des noms
Console.WriteLine(string.Join(", ", Enum.GetNames<Mentions>()));
// entier --> énumération
Mentions m3 = (Mentions)3;
Console.WriteLine($"(Mentions)3 = {m3}");
// chaîne --> énumération
if (Enum.TryParse("Bien", out Mentions m2))
{
    Console.WriteLine($"m2={m2}, m2 > Passable : {m2 > Mentions.Passable}");
}
// une énumération dans une expression switch
string appreciation = maMention switch
{
    Mentions.Passable or Mentions.AssezBien => "correct",
    Mentions.Bien or Mentions.TresBien => "très correct",
    _ => "remarquable"
};
Console.WriteLine($"appréciation : {appreciation}");
Console.WriteLine($"type : {typeof(Mentions).FullName}, type sous-jacent : {Enum.GetUnderlyingType(typeof(Mentions)).Name}");

// la déclaration d'un type se place après les instructions de haut niveau
enum Mentions { Passable, AssezBien, Bien, TresBien, Excellent }

Résultats :

> dotnet run bases-25.cs
mention=Passable
Peut mieux faire
Passable = 0
AssezBien = 1
Bien = 2
TresBien = 3
Excellent = 4
Passable, AssezBien, Bien, TresBien, Excellent
(Mentions)3 = TresBien
m2=Bien, m2 > Passable : True
appréciation : correct
type : Mentions, type sous-jacent : Int32

Commentaires :

  • ligne 3 : une variable de type [Mentions] ;
  • ligne 5 : une valeur d'énumération est affichée par son nom ;
  • lignes 12-14 : la liste des valeurs. La conversion [(int)m] donne la valeur entière de la constante ;
  • ligne 17 : la liste des noms ;
  • ligne 19 : la conversion inverse, d'un entier vers l'énumération ;
  • ligne 22 : la conversion d'une chaîne en valeur de l'énumération. Les valeurs d'une énumération se comparent comme les entiers qui les codent ;
  • lignes 27-31 : les énumérations se prêtent bien aux expressions switch ;
  • ligne 34 : le type entier sous-jacent est [int] par défaut ;
  • ligne 37 : la déclaration de l'énumération. Dans un fichier à instructions de haut niveau, les déclarations de types (énumérations, classes, ...) se placent après les instructions.

Une énumération dont les valeurs sont des puissances de 2 (1, 2, 4, 8, ...) peut recevoir l'attribut [Flags] : ses valeurs se combinent alors avec l'opérateur | (OU bit à bit) pour représenter un ensemble d'options, par exemple les droits d'accès à un fichier (lecture | écriture).

3.10. Passage de paramètres à une fonction

Nous nous intéressons ici au mode de passage des paramètres d'une fonction. Considérons la fonction suivante :

1
2
3
4
5
static void ChangeInt(int a)
{
    a = 30;
    Console.WriteLine($"Paramètre formel a={a}");
}

Dans la définition de la fonction, ligne 1, [a] est appelé un paramètre formel. Il n'est là que pour les besoins de la définition de la fonction [ChangeInt]. Il aurait tout aussi bien pu s'appeler [b]. Considérons maintenant une utilisation de cette fonction :

1
2
3
int age = 20;
ChangeInt(age);
Console.WriteLine($"Paramètre effectif age={age}");

Ici, dans l'instruction de la ligne 2, [age] est le paramètre effectif qui va transmettre sa valeur au paramètre formel [a]. Nous nous intéressons à la façon dont un paramètre formel récupère la valeur d'un paramètre effectif.

3.10.1. Les modes de passage

C# propose les modes de passage suivants :

  • par valeur (le mode par défaut) : la valeur du paramètre effectif est recopiée dans le paramètre formel correspondant. On a deux entités distinctes : si la fonction modifie le paramètre formel, le paramètre effectif n'est en rien modifié. Ce mode convient aux paramètres d'entrée d'une fonction ;
  • par référence avec [ref] : le paramètre effectif et le paramètre formel sont une seule et même entité. Si la fonction modifie le paramètre formel, le paramètre effectif est lui aussi modifié. Le mot clé [ref] doit figurer à la fois dans la définition de la fonction et dans l'appel. Le paramètre effectif doit avoir une valeur avant l'appel. Ce mode convient aux paramètres d'entrée-sortie ;
  • par référence avec [out] : comme [ref], mais le paramètre est uniquement un paramètre de sortie : le paramètre effectif n'a pas besoin de valeur initiale, et la fonction doit affecter une valeur au paramètre formel. C'est le mode utilisé par les méthodes [TryParse]. Depuis C# 7, la variable peut être déclarée dans l'appel : [out var x] ou [out int x] ;
  • par référence en lecture seule avec [in] (C# 7.2) : le paramètre est passé par référence, ce qui évite la copie, mais la fonction ne peut pas le modifier. Ce mode n'a d'intérêt que pour des structures volumineuses ; pour les types prédéfinis, le passage par valeur est aussi efficace.

Le programme [bases-26.cs] illustre ces différents modes :

// modes de passage des paramètres
// 1 - passage par valeur
int age = 20;
ChangeInt(age);
Console.WriteLine($"Paramètre effectif age={age}");

// 2 - passage par référence avec ref
int age2 = 20;
ChangeInt2(ref age2);
Console.WriteLine($"Paramètre effectif age2={age2}");

// 3 - passage par référence avec out : pas besoin d'initialiser la variable
int age3;
ChangeInt3(out age3);
Console.WriteLine($"Paramètre effectif age3={age3}");
// out var (C# 7) : la variable est déclarée dans l'appel
ChangeInt3(out var age4);
Console.WriteLine($"Paramètre effectif age4={age4}");

// 4 - un échange de valeurs avec ref
int x = 1, y = 2;
Echanger(ref x, ref y);
Console.WriteLine($"après échange : x={x}, y={y}");

// 5 - passage par référence en lecture seule avec in
decimal prix = 100M;
Console.WriteLine($"TTC({prix})={Ttc(in prix)}");

// 6 - un tableau passé par valeur : c'est sa référence qui est copiée
int[] tableau = [1, 2, 3];
ModifierTableau(tableau);
Console.WriteLine($"tableau=[{string.Join(", ", tableau)}]");

// les fonctions locales
static void ChangeInt(int a)
{
    a = 30;
    Console.WriteLine($"Paramètre formel a={a}");
}

static void ChangeInt2(ref int a2)
{
    a2 = 30;
    Console.WriteLine($"Paramètre formel a2={a2}");
}

static void ChangeInt3(out int a3)
{
    a3 = 30;
    Console.WriteLine($"Paramètre formel a3={a3}");
}

static void Echanger(ref int a, ref int b)
{
    int temp = a;
    a = b;
    b = temp;
}

static decimal Ttc(in decimal montant)
{
    // montant = 0; serait refusé : un paramètre in ne peut pas être modifié
    return montant * 1.2M;
}

static void ModifierTableau(int[] t)
{
    // on modifie le tableau référencé : l'appelant voit la modification
    t[0] = 100;
    // on fait pointer t sur un autre tableau : l'appelant ne le voit pas
    t = [7, 8, 9];
    Console.WriteLine($"dans ModifierTableau : t=[{string.Join(", ", t)}]");
}

Résultats :

> dotnet run bases-26.cs
Paramètre formel a=30
Paramètre effectif age=20
Paramètre formel a2=30
Paramètre effectif age2=30
Paramètre formel a3=30
Paramètre effectif age3=30
Paramètre formel a3=30
Paramètre effectif age4=30
après échange : x=2, y=1
TTC(100)=120,0
dans ModifierTableau : t=[7, 8, 9]
tableau=[100, 2, 3]

Commentaires :

  • lignes 3-5 : la valeur 20 du paramètre effectif [age] a été recopiée dans le paramètre formel [a] (ligne 35). Celui-ci a ensuite été modifié (ligne 37). Le paramètre effectif est resté inchangé ;
  • lignes 8-10 : avec [ref], le paramètre effectif [age2] a suivi la modification du paramètre formel [a2] ;
  • lignes 13-15 : la variable [age3] n'est pas initialisée avant l'appel : c'est possible avec [out] ;
  • ligne 17 : la variable [age4] est déclarée dans l'appel ;
  • lignes 21-22 : l'échange de deux valeurs nécessite le passage par référence : avec un passage par valeur, la fonction échangerait des copies ;
  • ligne 27 : un passage en lecture seule. Le mot clé [in] est facultatif dans l'appel. Le résultat 120,0 a une décimale parce que le produit de deux [decimal] conserve les décimales de ses opérandes : 100 × 1,2 = 120,0 ;
  • lignes 30-32 : un tableau est un type référence : ce qui est passé par valeur, c'est la référence du tableau. La fonction [ModifierTableau] travaille donc sur le même tableau que l'appelant : la modification de [t[0]] (ligne 69) est visible de l'appelant. En revanche, faire pointer [t] sur un autre tableau (ligne 71) ne modifie que la copie locale de la référence.

Si l'on remplace la ligne 8 par [int age2;], le compilateur refuse le programme :

error CS0165: Utilisation d'une variable locale non assignée 'age2'

On peut contourner l'obstacle en affectant une valeur initiale à [age2], ou en remplaçant le mot clé [ref] par le mot clé [out] : on exprime alors que le paramètre est uniquement un paramètre de sortie. De même, si la fonction [Ttc] essaie de modifier son paramètre [in] :

error CS8331: Impossible d’assigner à variable 'montant' ou de l’utiliser comme partie droite d’une affectation ref, car il s’agit d’une variable en lecture seule

3.10.2. Paramètres optionnels, arguments nommés, nombre variable de paramètres

C# 4 a ajouté les paramètres optionnels : un paramètre peut recevoir une valeur par défaut dans la définition de la fonction ; l'appelant peut alors l'omettre. Les paramètres optionnels se placent après les paramètres obligatoires. Il a ajouté aussi les arguments nommés : à l'appel, on peut faire précéder un paramètre effectif du nom du paramètre formel suivi de deux-points. On peut alors omettre un paramètre optionnel qui en précède d'autres, ou donner les paramètres dans un ordre quelconque.

Le mot clé [params] permet d'écrire une fonction à nombre variable de paramètres : le dernier paramètre formel est un tableau, et l'appelant peut passer soit un tableau, soit une liste de valeurs (éventuellement vide), que le compilateur range dans un tableau. Depuis C# 13, le paramètre [params] peut aussi être d'un autre type de collection, comme [List<T>] ou [ReadOnlySpan<T>].

Le programme [bases-27.cs] est le suivant :

// paramètres optionnels, arguments nommés, nombre variable de paramètres
// paramètres optionnels (C# 4) : ils ont une valeur par défaut
Console.WriteLine(Saluer("Pierre"));
Console.WriteLine(Saluer("Pierre", "Bonsoir"));
// arguments nommés (C# 4) : on peut sauter un paramètre optionnel ou changer l'ordre
Console.WriteLine(Saluer("Pierre", majuscules: true));
Console.WriteLine(Saluer(majuscules: true, formule: "Salut", nom: "Paul"));

// params : nombre variable de paramètres
Console.WriteLine($"Somme()={Somme()}");
Console.WriteLine($"Somme(1, 2, 3)={Somme(1, 2, 3)}");
int[] valeurs = [10, 20, 30];
Console.WriteLine($"Somme(valeurs)={Somme(valeurs)}");
// une méthode du framework à nombre variable de paramètres
Console.WriteLine(string.Join(" - ", "a", "b", "c"));

static string Saluer(string nom, string formule = "Bonjour", bool majuscules = false)
{
    string message = $"{formule} {nom}";
    return majuscules ? message.ToUpper() : message;
}

static int Somme(params int[] nombres)
{
    // nombres est un tableau
    int somme = 0;
    foreach (int nombre in nombres)
    {
        somme += nombre;
    }
    return somme;
}

Résultats :

1
2
3
4
5
6
7
8
9
> dotnet run bases-27.cs
Bonjour Pierre
Bonsoir Pierre
BONJOUR PIERRE
SALUT PAUL
Somme()=0
Somme(1, 2, 3)=6
Somme(valeurs)=60
a - b - c

Commentaires :

  • ligne 17 : la fonction [Saluer] a un paramètre obligatoire [nom] et deux paramètres optionnels [formule] et [majuscules] ;
  • ligne 3 : les deux paramètres optionnels prennent leur valeur par défaut ;
  • ligne 4 : seul [majuscules] prend sa valeur par défaut ;
  • ligne 6 : grâce à l'argument nommé, on passe [majuscules] sans passer [formule] ;
  • ligne 7 : avec des arguments nommés, l'ordre est libre ;
  • ligne 23 : [Somme] accepte un nombre quelconque d'entiers ;
  • lignes 10-13 : on appelle [Somme] sans paramètre, avec trois entiers, puis avec un tableau ;
  • ligne 15 : de nombreuses méthodes .NET, comme [string.Join] ou [Console.WriteLine] avec un format, ont un paramètre [params].

3.11. Les fonctions locales et les tuples

3.11.1. Les fonctions locales

Une fonction locale (C# 7) est une fonction définie à l'intérieur d'une méthode, ou, comme dans nos exemples, parmi les instructions de haut niveau. Elle n'est utilisable qu'à cet endroit. Puisque nos programmes n'ont plus de classe, les fonctions locales y remplacent les méthodes statiques des exemples de 2008. Une fonction locale :

  • peut être appelée avant sa définition ;
  • peut utiliser les variables locales qui l'entourent, sauf si elle est déclarée [static] : le mot clé [static] (C# 8) garantit qu'elle ne dépend que de ses paramètres, ce qui la rend plus facile à comprendre. Nous déclarons nos fonctions locales [static] chaque fois que c'est possible ;
  • peut avoir un corps d'expression : [static int Carre(int x) => x * x;] est équivalent à un corps [{ return x * x; }].

Dans un programme plus important, on écrit des classes et des méthodes : c'est l'objet du chapitre suivant.

3.11.2. Les tuples

Un tuple (C# 7) regroupe plusieurs valeurs, éventuellement de types différents, en une seule. Le type [(int, string)] désigne un couple formé d'un entier et d'une chaîne ; ses éléments s'appellent [Item1] et [Item2]. On peut les nommer : [(int Numero, string Nom)]. Un tuple peut être déconstruit : [var (numero, nom) = t;] range ses éléments dans deux variables.

Les tuples permettent à une fonction de rendre plusieurs valeurs sans utiliser de paramètres [out] : c'est souvent plus lisible.

Le programme [bases-28.cs] est le suivant :

// fonctions locales et tuples
// une fonction locale à corps d'expression
static int Carre(int x) => x * x;
Console.WriteLine($"Carre(7)={Carre(7)}");

// une fonction locale non statique peut utiliser les variables du programme
int compteur = 0;
void Incrementer() => compteur++;
Incrementer();
Incrementer();
Console.WriteLine($"compteur={compteur}");

// un tuple regroupe plusieurs valeurs
(int, string) t1 = (1, "un");
Console.WriteLine($"t1={t1}, t1.Item1={t1.Item1}, t1.Item2={t1.Item2}");
// les éléments d'un tuple peuvent être nommés
(int Numero, string Nom) t2 = (2, "deux");
var t3 = (Numero: 3, Nom: "trois");
Console.WriteLine($"t2.Numero={t2.Numero}, t3.Nom={t3.Nom}");
// déconstruction : un tuple est éclaté dans plusieurs variables
var (numero, nom) = t3;
Console.WriteLine($"numero={numero}, nom={nom}");

// une fonction qui rend plusieurs valeurs sans paramètres out
var (quotient, reste) = Diviser(17, 5);
Console.WriteLine($"17 = 5 * {quotient} + {reste}");
var mm = MinMax([12, -4, 35, 7]);
Console.WriteLine($"min={mm.Min}, max={mm.Max}");
// le framework utilise aussi les tuples
var (q, r) = Math.DivRem(23, 4);
Console.WriteLine($"Math.DivRem(23, 4) : quotient={q}, reste={r}");

// échange de deux variables avec un tuple
int a = 1, b = 2;
(a, b) = (b, a);
Console.WriteLine($"a={a}, b={b}");

static (int Quotient, int Reste) Diviser(int a, int b) => (a / b, a % b);

static (int Min, int Max) MinMax(int[] valeurs)
{
    int min = valeurs[0], max = valeurs[0];
    foreach (int v in valeurs)
    {
        if (v < min) min = v;
        if (v > max) max = v;
    }
    return (min, max);
}

Résultats :

> dotnet run bases-28.cs
Carre(7)=49
compteur=2
t1=(1, un), t1.Item1=1, t1.Item2=un
t2.Numero=2, t3.Nom=trois
numero=3, nom=trois
17 = 5 * 3 + 2
min=-4, max=35
Math.DivRem(23, 4) : quotient=5, reste=3
a=2, b=1

Commentaires :

  • ligne 3 : une fonction locale statique à corps d'expression ;
  • lignes 7-8 : une fonction locale non statique : elle modifie la variable [compteur] du programme ;
  • ligne 14 : un tuple dont les éléments ne sont pas nommés. L'affichage d'un tuple donne ses éléments entre parenthèses ;
  • lignes 17-18 : deux tuples aux éléments nommés : le premier par son type, le second par les noms donnés dans l'expression ;
  • ligne 21 : la déconstruction du tuple [t3] ;
  • ligne 25 : la fonction [Diviser] rend le quotient et le reste de la division entière ; on déconstruit directement le résultat ;
  • ligne 27 : on garde ici le tuple et l'on utilise les noms de ses éléments [Min] et [Max] ;
  • ligne 30 : depuis .NET 6, [Math.DivRem] rend lui aussi un tuple (quotient, reste) ;
  • ligne 35 : l'échange de deux variables s'écrit en une ligne avec un tuple, sans variable temporaire ;
  • ligne 38 : le type rendu par la fonction est un tuple nommé ;
  • lignes 40-48 : une fonction qui calcule en une passe le minimum et le maximum d'un tableau.