C# – Passage de paramètres

Notez que ref et out sont tous deux des passages par référence. Les différences entre les deux sont que :

Le passage de paramètres en C# se fait selon quatre modalités :

Ce qui suit donne quelques détails sur chacune de ces modalités.

Passage par valeur

Le passage par valeur est la modalité par défaut de C#. Aucun mot clé n'est nécessaire. Toutefois, du fait que C# favorise (tristement) l'accès indirect aux objets, il faut comprendre que toute instance d'une classe est manipulée par référence, et que copier une référence implique partager un référé.

Quelques exemples :

À droite, G appelle F en lui passant par valeur la variable n locale à G.

Conséquemment, la variable n locale à F est une copie de la variable n locale à G. Quand F modifie n, elle modifie la copie locale à F, et laisse la variable n locale à G intacte (ce sont deux variables distinctes).

L'affichage dans G montre donc que n dans G est demeurée intacte suivant les actions de F.

// ...
static int F(int n)
{
   ++n; // modifie le n local à F
   return n;
}
static void G()
{
   int n = 3;
   int m = F(n); // passage par valeur
   // Après l'appel, m : 4 et n : 3
   Console.Write($"Après l'appel, m : {m} et n : {n}");
}
// ...

De manière semblable, à droite, Adapter reçoit en paramètre une string. Les string en C# sont des variables de types références (des class), donc Adapter reçoit une copie d'une référence (initialement, dans Adapter, la variable msg réfère au même endroit que la variable s dans Déclamer).

Toutefois, l'écriture msg = msg + "!" dans Adapter fait pointer msg, qui est une référence locale à Adapter, ailleurs. La chaîne originale (s dans Déclamer) demeure intacte... De toute manière, les string de C# sont immuables, donc non-modifiables une fois construites.

static string Adapter(string msg)
{
   msg = msg + "!";
   return msg;
}
static void Déclamer()
{
   string s = "J'aime mon prof";
   string msg = Adapter(s);
   Console.Write($"Avant : \"{s}\", après : \"{msg}\""); 
}

Avec un objet mutable, le passage par valeur peut être trompeur, étant donné le modèle de C# qui mène par défaut au partage des objets.

À droite, le type Entier est mutable (sa propriété Valeur est modifiable post-construction). Quand la fonction Zut appelle la fonction Oups, elle lui passe l'instance et de Entier par valeur, ce qui provoque une copie de e (autrement dit, les variables e et Oups et de Zut sont distinctes), mais les deux variables réfèrent au même objet.

Pour cette raison, quand Oups modifie la valeur de l'objet référé par e, elle modifie aussi la valeur de l'objet référé par e dans Zut.

class Entier
{
   public int Valeur{ get; set; } = 0; // mutable
   public Entier()
   {
   }
   public Entier(int valeur)
   {
      Valeur = valeur;
   }
}
// e est passé par valeur, mais e est une référence et
// le référé est partagé entre l'appelant et l'appelé
static void Oups(Entier e)
{
   e.Valeur++;
}
static void Zut()
{
   var e = new Entier(3);
   Console.WriteLine($"Avant : {e.Valeur}"); // Avant : 3
   Oups(e);
   Console.WriteLine($"Après : {e.Valeur}"); // Après : 4
}

Exemple simple : à droite, la fonction Neg transforme les valeurs du tableau qui lui est passé en paramètre en leur négation arithmétique, ce qui modifie les éléments du tableau suppléé par le code client du fait que le tab, passé par valeur, est une copie d'une référence

static void Neg(int [] tab)
{
   for(int i = 0; i != tab.Length; ++i)
      tab[i] = -tab[i];
}

Passage par référence

Le mot clé ref appliqué à un paramètre en C# indique un passage par référence. Avec ce mode de passage de paramètre, le paramètre doit être initialisé avant l'appel, mais l'appelant peut faire référer le paramètre ailleurs que là où il référait à l'origine. C# exige que le code client appose ref au point d'appel dans un tel cas (ce ne sont pas tous les langages qui imposent cette répétition).

L'exemple classique de passage par référence est une fonction qui permute les valeurs de deux variables (ici, des int). Une telle fonction est utile dans une multitude de situations, incluant à peu près tous les algorithmes de tri.

// ...
static void Permuter(ref int a, ref int b)
{
   int temp = a;
   a = b;
   b = temp;
}
// ...

Notez qu'un passage par référence ne peut se faire que sur une variable dans laquelle l'appelé peut écrire. Le code à droite montre un appel correct à la fonction Permuter.

Ainsi, ceci serait illégal :

static int F() => 2;
static int G() => 3;
// ...
Permuter(ref F(), ref G()); // illégal, pas des variables

... et ceci serait illégal aussi :

Permuter(ref 2, ref 3); // illégal, pas des variables
// ... programme principal
int m = 2;
int n = 3;
// Avant l'appel, m : 2 et n : 3
Console.Write($"Avant l'appel, m : {m} et n : {n}");
Permuter(ref m, ref n);
// Après l'appel, m : 3 et n : 2
Console.Write($"Après l'appel, m : {m} et n : {n}");
// ... la fonction Permuter va ci-dessous

Mon collègue Philippe Simard m'a proposé cet exemple (que j'ai un peu simplifié) qui montre sous un autre angle l'impact d'appliquer ou pas ref à un paramètre :

  • Quand Main passe tab par valeur à EhBen, cette fonction fait pointer sa variable locale tab sur un nouveau tableau de taille 2, mais dans Main, la variable tab pointe toujours sur un tableau de taille 5 suite à l'appel
    • notez que les deux variables (tab dans Main et tab dans EhBen) pointent au même tableau; si l'on modifiait les éléments du tableau à travers l'une d'elles, on pourrait constater les changements à travers l'autre
  • Quand Main passe tab par référence à CouDonc, cette fonction fait pointer sa variable locale tab sur un nouveau tableau de taille 2, mais puisque cette variable réfère à la variable tab dans Main, la variable tab pointe désormais sur ce tableau de taille 2 suite à l'appel
using System;

int [] tab = { 2,3,5,7,11 };
Console.WriteLine(tab.Length); // 5
EhBen(tab);
Console.WriteLine(tab.Length); // 5
CouDonc(ref tab);
Console.WriteLine(tab.Length); // 2

static void EhBen(int[] tab)
{
   tab = new int[2];
}
static void CouDonc(ref int[] tab)
{
   tab = new int[2];
}

Paramètre sortant

Le mot clé out appliqué à un paramètre en C# indique un paramètre sortant. Avec ce mode de passage de paramètre, le paramètre peut être initialisé avant l'appel mais n'a pas à l'être, car l'appelant a l'obligation d'y écrire. C# exige que le code client appose out au point d'appel dans un tel cas (ce ne sont pas tous les langages qui imposent cette répétition).

Un exemple classique de passage sortant est une fonction qui doit avoir deux extrants, par exemple int.TryParse qui doit à la fois indiquer à l'appelant (a) si la traduction d'une string en entier a fonctionné ou non, et (b) si cette traduction a fonctionné, quel est l'entier résultant.

Comme dans le cas de ref, il faut qu'un paramètre passé selon la modalité out soit une variable dans laquelle il est possible d'écrire, ce qui exclut une valeur temporaire anonyme retournée par une fonction, le résultat d'une expression arithmétique ou un littéral par exemple.

// ...
string s = Console.ReadLine();
int nb; // note : pas initialisée
if(int.TryParse(s, out nb))
{
   Console.WriteLine($"Entier lu : {nb}");
}
else
{
   // la valeur de nb n'est pas pertinente ici
   Console.WriteLine($"Échec à la traduction de {s} en entier");
}
// ...

C# permet aussi de déclarer un paramètre out à son point d'utilisation. Cette syntaxe étrange est équivalente à la précédente, plus classique, et vise à réduire la quantité de variables non-initialisées flottant dans un programme. Elle n'a d'ailleurs de sens que pour out, car tous les autres modes de passage de paramètres requièrent que les paramètres soient initialisés avant l'appel.

// ...
string s = Console.ReadLine();
if(int.TryParse(s, out int nb))
{
   Console.WriteLine($"Entier lu : {nb}");
}
// ...

Une implémentation très naïve d'une fonction semblable à TryParse pourrait être celle (inefficace) proposée à droite. Notez que la variable nb est initialisée (ici, à zéro, mais c'est arbitraire) même si la traduction de la string en int a échoué, car une fonction a l'obligation d'écrire dans chaque paramètre out.

// ...
static bool TryParseNaïf(string s, out int nb)
{
   try
   {
      nb = int.Parse(s);
      return true;
   }
   catch(Exception)
   {
      nb = 0;
      return false;
   }
}
// ...

Paramètre intrant

Le mot clé in appliqué à un paramètre en C# indique un paramètre intrant. C'est un parent pauvre des paramètres const de C++, mais qui vise un objectif connexe, soit celui de faire un paramètre que l'appelé ne peut pas modifier. Notez la distinction entre passage par valeur et passage in : le passage par valeur provoque une copie, que l'appelé peut modifier à loisir, alors qu'un passage in est un passage par référence mais qui ne permet pas à l'appelé de modifier le paramètre.

Un exemple de passage intrant est proposé à droite. Permettons-nous du même coup quelques remarques recoupant les autres modalités de passage de paramètre :

  • Dans Fct, l'instruction commentée (note 0) est illégale car l'appelé ne peut modifier un paramètre in
  • Les signatures de Fct commentées (notes 1 et 2) sont illégales. C# ne permet pas de surcharger une fonction sur la seule base des qualifications in, out et ref... ce qui est étrange, quand on y pense, du fait que le code appelant doit généralement (une exception suit ci-dessous) expliciter la modalité de passage de paramètre souhaitée, mais bon, c'est C#

Le programme principal montre pour sa part quelques exemples d'appels utilisant un paramètre in :

  • L'instruction commentée (note 3) est illégale car un paramètre in doit être initialisé au point d'appel
  • Il est possible de passer un paramètre in en explicitant in, ou (contrairement aux paramètres ref et out) en l'omettant. De même, contrairement aux passages ref et out, un passage in peut accepter un littéral, la valeur d'une expression, une temporaire anonyme, etc.
// ...
class Tests
{
   static int Fct(in int n)
   {
      // n = 3; // 0 : illégal
      return n + 1; // Ok
   }
   // 1 : illégal
   //static void Fct(ref int n) { /* ... */ }
   // 2 : illégal
   //static void Fct(out int n) { /* ... */ }
   static void Test(string[] args)
   {
      int n0;
      // Fct(n0); // 3 : illégal
      n0 = 3;
      Fct(in n0); // Ok
      Fct(n0); // Ok aussi
      Fct(-8); // Ok
   }
}
// ...

La « protection » d'un paramètre in est toutefois très « locale » (c'est aussi le cas d'un paramètre const en C++, d’ailleurs). L'exemple suivant (que j'ai pris à Jon Skeet) en montre les limites :

using System;

string? text = "Not null";
Action action = () => text = null;
// boum!
int length = LengthIfPossible(in text, action);

static int LengthIfPossible(in string? text, Action action)
{
   if (text == null)
      return -1;
   action();
   return text.Length; // boum!
}

Lectures complémentaires

Quelques liens pour complémenter le propos.

 


Valid XHTML 1.0 Transitional

CSS Valide !