Manipuler des objets de type valeur par référence (C# 7)

Cet article fait partie d’une série d’articles sur les apports fonctionnels de C# 7 (i.e. C# 7.0/7.1/7.2/7.3).

Avant de commencer…

Quelques indications en préambule concernant les objets de type valeur, les objets de type référence, le boxing et l’unboxing.

Type valeur vs type référence

D’une façon générale, il existe des objets de type valeur et des objets de type référence. Ces objets peuvent être manipulés par valeur ou par référence. En .NET, dans la plupart des cas et par défaut:

  • les objets de type référence sont manipulés par référence toutefois il est possible de manipuler des objets de type référence en utilisant des pointeurs avec du code unsafe.
  • les objets de type valeur sont manipulés par valeur, dans certaines conditions on peut manipuler des objets de type valeur en utilisant des références avec les mot-clés ref, in (à partir de C# 7.2) ou out.

Parmi les types valeur, on peut trouver les structures, les enums et les types primitifs comme bool, int, double, float etc… La plupart du temps les objets de type valeur sont immutables c’est-à-dire qu’il n’est pas possible d’en modifier des propriétés sans devoir créer une nouvelle instance de ces objets. Par exemple, l’affectation d’un objet de type valeur effectue une copie par valeur:

int firstValue = 5; 
int secondValue = firstValue; // Copie par valeur 
List<int> values = new List<int>(firstValue); // Copie par valeur 
int thirdValue = values.First(); // Copie par valeur 

L’opposé de immutable est mutable. Des objets sont dit mutables s’il est possible de les modifier sans devoir créer une nouvelle instance. Il existe quelques cas où un objet de type valeur est mutable. Si on considère la structure suivante:

public struct Circle 
{ 
  public int Radius { get; set; } 
} 

Cette structure est mutable si:

  • Une propriété locale est modifiée:
    var circle = new Circle(); 
    circle.Radius = 6; // Pas de copie, circle est modifié 
    
  • Si la structure se trouve dans un objet de type référence comme une classe:
    class Wrapper 
    { 
      public Circle InnerCircle; 
    } 
    
    var wrapper = new Wrapper{ InnerCircle = circle }; // Copie par valeur de circle 
    wrapper.InnerCircle.Radius; // Pas de copie, la copie de circle est modifiée directement 
    
  • Si on utilise un objet de type valeur dans un tableau:
    Circle[] circles = new Circle[] { circle }; // Copie par valeur 
    circles[0].Radius = 7; // Pas de copie, la copie dans le tableau est modifiée directement 
    circles.First().Radius = 4;// ATTENTION: First() effectue une copie. 
    var newCircle = circles[0]; // ATTENTION: une copie est effectuée.
    

Implicitement, les objets de type référence dérivent de System.Object et les objets de type valeur dérivent de System.ValueType. En réalité System.ValueType dérive de System.Object, la différence entre les objets de type valeur et les objets de type référence est artificielle du point de vue de la hiérarchie des classes. C’est le runtime qui différenciera ces 2 types d’objets à l’exécution. Ainsi, les objets de type référence sont stockés dans le tas managé (i.e. managed heap) et les objets de type valeur sont stockés le plus souvent dans la pile (i.e. stack).

Objets de type référence

L’intérêt principal d’utiliser des objets de type référence est de les stocker dans le tas managé et de pouvoir allouer des quantités variables de mémoire suivant la taille des objets de façon dynamique. L’accès à ces objets se fait par l’intermédiaire d’une référence. Une référence permet de pointer vers un objet stocké en mémoire en utilisant son adresse. La référence d’un objet de type référence est elle-même un objet de type valeur. Par définition, une référence ne peut pas être nulle (en revanche, une variable contenant une référence peut être nulle).
La manipulation d’une référence n’est pas très couteuse car sa taille est fixe et égale à la valeur de la constante System.IntPtr.Size (la taille varie suivant la taille des adresses mémoire 32 ou 64 bits du système). La référence d’un objet stocké dans le tas managé peut donc facilement être stockée dans une variable sur la pile ou être passée en argument d’une fonction.

Référence .NET vers un objet de type référence

L’inconvénient majeur des objets de type référence est qu’ils sont couteux à manipuler car stockés dans le tas managé, les objets devant y être alloués et désalloués. La gestion de la mémoire dans le tas est assurée par le Garbage Collector (GC), ce qui nécessite de nombreuses opérations pour différencier les objets utilisés des objets qui ne le sont plus ou pour réorganiser les zones allouées en mémoire pour optimiser les temps d’allocation. Toutes ces opérations peuvent avoir un impact non négligeable sur les temps d’exécutions.

Le GC divise les objets managés en 2 catégories:

  • Les petits objets (< 85000 octets): ils sont gérés par génération (génération 0, génération 1 et génération 2) et leur allocation est rapide. Quand il change de génération (promotion), ils sont copiés dans la mémoire. La désallocation de ces objets est non déterministe et bloquante. Les objets dont la durée de vie est faible sont supprimés rapidement et se trouvent dans la génération 0 ou 1. Les objets dont la durée de vie est longue se trouvent dans la génération 2 et leur manipulation est plus longue.
  • Les gros objets (≥ 85000 octets): ils sont alloués dans un tas appelé Large Object Heap (LOH) dont le contenu n’est jamais déplacé. Cette caractéristique peut mener à une fragmentation de cette partie de la mémoire et allonge le temps d’allocation pour les nouveaux objets.

Objets de type valeur

Les objets de type valeur sont stockés dans la pile (i.e. stack) toutefois ce n’est pas tout le temps le cas, par exemple:

  • Les objets statiques de type valeur sont stockés dans un tas particulier (loader heap ou high frequency heap).
  • Un objet de type valeur membre d’un objet de type référence peut être stocké dans le tas managé.
  • Les objets de type valeur peuvent aussi être stockés dans un registre CPU suivant les optimisations du JIT.

L’intérêt des objets de type valeur est qu’ils peuvent être alloués et désalloués de la pile rapidement. Quand une fonction est appelée, un bloc appelé stack frame est réservé au sommet de la pile pour les variables locales. Quand une fonction a terminé son exécution, le bloc n’est plus utilisé et peut être utilisé lors de l’appel à une autre fonction suivant l’ordre LIFO (i.e. Last In First Out). La libération de ce bloc de mémoire est simple et beaucoup plus rapide qu’avec le tas managé. D’autre part, la taille de la pile est très petite (< 1 MO) et peut facilement entrer dans le cache d’un CPU. La manipulation des objets dans la pile est donc rapide et convient bien aux opérations répétitives.

Le plus gros inconvénient des objets de type valeur stockés dans la pile est que leur durée de vie est liée à la durée de vie de la fonction qui les manipule et que ces objets sont souvent copiés par valeur quand ils sont manipulés. La durée d’exécution de ces copies n’est pas fixe et varie suivant la taille des objets copiés. Il est possible de manipuler des références vers ces objets suivant certaines conditions.

Boxing vs Unboxing

Le boxing et l’unboxing sont 2 opérations courantes en .NET.

Boxing

En .NET, l’opération de boxing consiste à convertir un objet de type valeur vers le type object. Le plus souvent cette opération est effectuée implicitement.

Par exemple, si considère la structure suivante:

public struct Circle 
{ 
  public int Radius { get; set; } 
} 

Le boxing intervient si on stocke la structure dans un objet de type object:

Circle circle = new Circle(); 
object circleAsObj = circle; // boxing

La conversion en object est effectuée de façon implicite.

Techniquement l’opération de boxing est couteuse (20 fois plus longue qu’une simple assignation) car des nombreuses opérations sont effectuées. En .NET, les objets de type référence sont stockés dans le tas managé (i.e. managed heap) et les objets de type valeur sont stockés, la plupart du temps, dans la pile. La conversion de l’objet de type valeur en objet de type référence entraîne que cet objet doit être stocké dans le tas managé au lieu de la pile. Le boxing entraîne:

  • La création d’un wrapper de type référence qui va encapsuler l’objet de type valeur,
  • L’objet de type valeur est supprimé de la pile,
  • Il est placé dans le wrapper qui lui-même est placé dans le tas managé.

Outre l’opération de boxing à proprement parlé, d’autres utilisations de l’objet après boxing s’avère plus couteuse que l’utilisation directe de l’objet de type valeur puisqu’il faut accéder à l’objet dans le tas managé en utilisant sa référence alors que l’accès dans la pile était directe.

L’opération de boxing peut être mise en œuvre de façon moins évidente, par exemple, dans le cas où on stocke un objet de type valeur dans une liste générique d’object:

Circle circle = new Circle(); 
List<object> objects = new List<object>(); 
objects.Add(circle); // Boxing implicite 

D’autres opérations peuvent entraîner un boxing sans que l’on s’en rende compte, par exemple, si on appelle ToString() sur une structure, si on stocke un objet de type valeur dans une ArrayList ou une Hashtable:

ArrayList list = new ArrayList(); 
list.Add(circle); // Boxing implicite 

string circleAsStr = circle.ToString(); // Boxing implicite 

Le boxing peut intervenir aussi de façon implicite si une conversion intervient entre l’objet de type valeur et une interface.

Enfin, si observe le code décompilé avec IL DASM, l’opération de boxing apparaît sous la forme box, par exemple pour le code suivant:

var circle = new Circle(); 
object circleAsObj = circle; 
Console.WriteLine(circleAsObj.ToString()); 

La sortie de IL DASM est:

.method private hidebysig static void Main(string[] args) cil managed 
{ 
  .entrypoint 
  .maxstack 1 
  .locals init ([0] valuetype Test.Circle V_0) 
  IL_0000: ldloca.s V_0 
  IL_0001: initobj Test.Circle 
  IL_0008: ldloc.0 
  IL_0009: box Test.Circle

  IL_000e: callvirt instance string [mscorlib]System.Object::ToString() 
  IL_0013: call void [mscorlib]System.Console::WriteLine(string) 
  IL_0018: ret 
} 

Unboxing

A l’opposé du boxing, l’unboxing permet d’effectuer une conversion d’un type object vers un objet de type valeur. Le coût en performance est aussi significatif que pour le boxing c’est-à-dire que la ligne dans le code mettant en oeuvre l’unboxing entraîne techniquement plusieurs opérations qui la rendent plus couteuse qu’une simple affectation. La différence avec le boxing est que les opérations d’unboxing sont explicites donc il est plus facile de s’en rendre compte.

Par exemple, le code suivant implique de l’unboxing:

var circle = new Circle(); 
object circleAsObj = circle; // Boxing implicite 
var unboxedCircle = (Circle)circleAsObj; // Unboxing explicite 
Console.WriteLine(unboxedCircle.ToString()); 

D’un point de vue technique, étant donné que les objets de type valeur sont stockés dans la pile et que les objets de type référence sont stockés dans le tas managé (i.e. managed heap), l’opération d’unboxing implique davantage qu’une simple affectation. L’objet de type valeur se trouvant dans une variable object stockée dans le tas managé doit être extrait de son wrapper de type référence et déplacé dans la pile.

Si on observe le code décompilé avec IL DASM, l’opération d’unboxing apparaît sous la forme unbox. Par exemple pour le code plus haut, la sortie IL DASM est:

.method private hidebysig static void Main(string[] args) cil managed 
{ 
  .entrypoint 
  .maxstack 1 
  .locals init ([0] valuetype Test.Circle unboxedCircle, [1] valuetype Test_Circle V_1) 
  IL_0000: ldloca.s V_1 
  IL_0001: initobj Test.Circle 
  IL_0008: ldloc.0 
  IL_0009: box Test.Circle 
  IL_000e: unbox.any Test.Circle

  IL_0013: stloc.0 
  IL_0014: ldloca.s unboxedCircle 
  IL_0016: constrained. Test.Circle 
  IL_0021: callvirt instance string [mscorlib]System.Object::ToString() 
  IL_0026: call void [mscorlib]System.Console::WriteLine(string) 
  IL_0026: ret 
} 

Passage d’argument par référence d’un objet de type valeur

C# 7 permet d’effectuer des passages d’argument dans des fonctions d’objets de type valeur par référence.

C# 7.0

A partir de C# 7.0, il est possible d’effectuer des passages d’arguments d’objets de type valeur par référence. En effet, par défaut le passage d’objets de type valeur en argument de fonction se fait par valeur c’est-à-dire que les objets sont copiés lors de l’appel de fonction. L’utilisation du mot-clé ref dans un argument de fonction permet de passer un objet de type valeur par référence.

Techniquement lors du passage d’un objet de type valeur par référence, la référence passée en argument correspond à un objet permettant de pointer vers l’objet dans la pile. Il n’y a pas de boxing.

Par exemple, si on considère l’exemple suivant:

public struct Circle 
{ 
  public int Radius { get; set; } 
} 

private static void ChangeRadius(int newRadius, Circle circle) 
{ 
  circle.Radius = newRadius; 
} 

static void Main() 
{ 
  var circle = new Circle{ Radius = 4 }; 
  Console.WriteLine(circle.Radius); // 4 
  ChangeRadius(2, circle); // circle est dupliquée 
  Console.WriteLine(circle.Radius); // 4 
} 

Cet exemple ne fonctionne pas, la valeur de la propriété Radius est bien modifiée par ChangeRadius() toutefois il modifie la propriété d’une copie de l’objet d’origine. L’instance circle d’origine n’est pas modifiée.

Si on passe l’objet circle par référence, il n’y aura pas de copie et l’instance circle est réellement modifiée:

private static void ChangeRadius(int newRadius, ref Circle circle) 
{ 
  circle.Radius = newRadius; 
} 

static void Main() 
{ 
  var circle = new Circle{ Radius = 4 }; 
  Console.WriteLine(circle.Radius); // 4 
  ChangeRadius(2, ref circle); // circle est passé par référence 
  Console.WriteLine(circle.Radius); // 2 
} 

Si on regarde plus en détails l’implémentation:

  • ref désigne un objet permettant de pointer vers un objet de type valeur, ainsi la signature de la méthode ChangeRadius() comprend l’argument ref Circle circle. Le type est donc une référence vers un objet de type valeur Circle.
  • L’appel vers la méthode ChangeRadius() doit être modifié puisqu’il faut utiliser une référence et non directement le type valeur: ChangeRadius(2, ref circle).
Passage d’argument “par référence”

On utilise le terme “par référence” toutefois il ne s’agit pas de références .NET vers des objets managés. Quand le mot clé ref est utilisé pour des paramètres de fonction, un retour de fonction ou une variable locale, il s’agit d’un objet “ref” (i.e. ByRef objects) dans lequel se trouve un pointeur managé. Comme les références classiques, les pointeurs managés sont connus par le garbage collector, et ils permettent de pointer vers des objets managés. Toutefois techniquement ils sont très différents car ils ne contiennent qu’une adresse mémoire sans information supplémentaire et peuvent pointer, en plus des objets managés, vers des objets non managés, des objets se trouvant sur la pile ou à l’intérieur d’objets. D’autre part, à la différence des références classiques, les pointeurs managés sont exclusivement stockés sur la pile.

Objet ref vers un objet de type valeur

Passage en argument d’une référence par référence

Une référence est un objet de type valeur qui, par défaut, est manipulée par valeur. Manipuler une référence par référence permet, par exemple, d’éviter d’effectuer des copies de la référence. La référence de la référence correspond à un objet ref pointant vers la référence .NET d’un objet de type référence:

Objet ref pointant vers la référence d’un objet de type référence
C# 7.0

Lors du passage d’argument, utiliser ref pour des objets de type référence permet d’éviter la copie de la référence vers l’objet. En effet, la plupart du temps, les objets de type référence sont manipulés par référence, ainsi si on considère la classe suivante:

class Square 
{ 
  public int Size { get; set; } 
} 

var square = new Square { Size = 4 }; 

La variable square contient une référence vers un objet de type Square stocké dans le tas managé. La référence est un objet de type valeur bien que l’objet référencé est de type référence. La référence est stockée dans la pile alors que l’objet référencé est stockée dans le tas managé. Si on écrit:

var newSquare = square; // Copie de la référence 

Une nouvelle variable est créée et initialisée avec une copie de la référence vers l’objet newSquare. L’objet référencée n’est pas copié, seule la référence est copiée.

Ainsi si on considère la méthode suivante:

public static void IncreaseSize(Square square) 
{ 
  square.Size++; 
} 

Si on effectue un appel de ce type:

var initialSquare = new Square { Size = 2 }; 
IncreaseSize(initialSquare); 
Console.WriteLine(initialSquare.Size); // 3 

L’argument square contient une référence vers un objet de type Square. Lors de l’appel, la référence initialSquare est copiée dans l’argument square. Lorsqu’on manipule l’objet référencé avec square.Size, on manipule directement l’objet. Si on effectue une affectation à l’intérieur de la fonction sur l’argument square, l’objet initial n’est pas modifié et la variable otherSquare contient une référence vers l’objet initial:

public static void IncreaseSize(Square square) 
{ 
  square  = new Square{ Size = 8 }; // Création d'un nouvel objet et d'une nouvelle référence 
} 

var initialSquare = new Square { Size = 2 }; 
IncreaseSize(initialSquare); 
Console.WriteLine(initialSquare.Size); // 2 

Si on utilise ref dans l’argument de IncreaseSize(), on indique qu’on ne veut pas dupliquer la référence mais passer la référence en argument par référence:

public static void IncreaseSize(ref Square square) 
{ 
  square  = new Square{ Size = 8 }; // Création d'un nouvel objet et affectation à la référence existante 
} 

var initialSquare = new Square { Size = 2 }; 
IncreaseSize(ref initialSquare); // La référence n'est pas copiée 
Console.WriteLine(initialSquare.Size); // 8 

Dans cet exemple, la référence est passée par référence. La référence n’est donc pas copiée lors du passage en argument. Modifier la référence dans le corps de la méthode, va entraîner la modification de la référence à l’extérieur de la méthode.

Manipuler une variable locale par référence

C# 7.0

A chaque nouvelle affectation, les objets de type valeur sont copiés.

Par exemple:

var circle = new Circle { Radius = 4 }; 
var newCircle = circle; // Duplication de l’objet 
circle.Radius = 2; 
Console.WriteLine(circle.Radius); // 2 
Console.WriteLine(newCircle.Radius); // 4 

L’affection, le passage d’argument ou le retour de fonction sont des opérations qui effectuent une copie par valeur d’un objet de type valeur. L’affectation var newCircle = circle crée une nouvelle instance newCircle qui est une copie de l’instance circle.

L’affectation d’une nouvelle propriété Radius sur l’instance circle ne modifie pas l’instance newCircle puisqu’il s’agit d’objets différents.

A partir de C# 7.0, il est possible de manipuler des variables locales d’objet de type valeur par référence. Ainsi, les affectations effectuent une copie de référence au lieu d’effectuer une copie par valeur.

Si on reprends l’exemple précédent, il faut déclarer une référence d’un objet de type valeur:

var circle = new Circle { Radius = 4 }; 
ref var circleRef = ref circle; // On considère la référence de l’objet 
circleRef.Radius = 2; 
Console.WriteLine(circle.Radius); // 2 
Console.WriteLine(circleRef.Radius); // 2 

Si on regarde plus en détails la syntaxe de l’exemple:

  • var circle désigne un objet de type valeur Circle.
  • ref var circleRef désigne une référence vers un objet de type valeur Circle, ainsi les lignes:
    • var circle = new Circle { Radius = 4 } permet d’instancier un objet de type Circle.
    • ref circle permet de récupérer une référence vers l’objet circle dans la pile (i.e. stack). Plus précisemment, cet objet se trouve dans la stack frame de la méthode. Cette stack frame et les objets qu’elle contient existent tant que la méthode existe.
C# 7.3

A chaque fois, qu’on manipule une référence d’un objet de type valeur, il faut penser à utiliser le mot-clé ref, par exemple si on écrit:

Circle firstCircle = new Circle { Radius = 2 }; // Initialisation 
Circle secondCircle = new Circle { Radius = 4 }; // Initialisation 
ref Circle firstCircleRef = ref firstCircle;
ref Circle secondCircleRef = ref secondCircle;
secondCircleRef = firstCircleRef; // Affectation par valeur, une copie est effectuée 

Même si firstCircleRef et secondCircleRef sont définis avec ref Circle, la dernière affectation effectue une copie par valeur. Pour manipuler les références, il faut utiliser ref (disponible à partir de C# 7.3):

secondCircleRef = ref firstCircleRef; // Affectation par référence (C# 7.3) 

Avant C# 7.3, il n’est pas possible d’effectuer l’affectation d’une référence vers un objet de type valeur sur une variable existante, seules les initialisations sont possibles:

ref Circle otherFirstCircleRef = ref firstCircleRef; 

Manipuler une variable locale par référence en lecture seule (ref readonly)

C# 7.2

A partir de C# 7.2, il est possible d’indiquer que le membre d’une variable locale manipulée par référence n’est pas modifiable en déclarant la variable avec ref readonly. Ainsi si une variable est déclarée avec ref readonly, ses membres éventuels ne seront utilisables qu’en lecture seule, toute tentative d’affectation d’un membre conduira à une erreur de compilation:

var circle = new Circle { Radius = 4 };
ref readonly var readOnlyCircleRef = ref circle; // Initialisation d'une variable par référence en lecture seule

readOnlyCircleRef.Radius = 2; // ERREUR: modification d'un membre est non autorisée

La variable en lecture seule est, toutefois, réaffectable:

var otherCircle = new Circle { Radius = 2 };

readonlyCircleRef = ref otherCircle; // OK autorisée
readonlyCircleRef.Radius = 4; // ERREUR

Une variable déclarée avec ref readonly ne peut pas être passé en argument d’une méthode par référence. Si on considère la méthode:

void ChangeRadius(ref Circle circleToUpdate) {}

// ...
ChangeRadius(ref readonlyCircleRef); // ERREUR

Lors de l’initialisation de la variable locale avec ref readonly, la valeur d’initialisation utilisée peut aussi correspondre à un paramètre in d’une méthode, par exemple:

static void PrintCircleRadius(in Circle circle)
{
  ref readonly var readOnlyCircle = ref circle; // OK
  Console.WriteLine(readOnlyCircle.Radius);
}

Si une variable déclarée par référence est initialisée avec le membre d’un objet déclaré avec ref readonly ou avec le retour d’une fonction de type ref readonly T alors la variable doit être déclarée avec ref readonly.
Par exemple, si on considère la classe suivante:

class CircleWrapper
{
  private ref readonly circle = new Circle { Radius = 4 };

  public void PrintCircleRadius()
  {
    ref readonly var readOnlyLocalCircle = ref this.circle; // OK

    ref var localCircle = ref this.circle; // ERREUR
  }
}

Si on considère la méthode suivante:

static Circle circle = new Circle { Radius = 4 };
static ref readonly Circle GetCircle()
{
  return ref circle;
}

Une variable initialisée avec le retour de la fonction doit être de type ref readonly Circle:

ref readonly var circle = GetCircle(); // OK

ref var circle = GetCircle();  // ERREUR

Enfin, la valeur d’initialisation d’une variable déclarée avec ref readonly doit être une LValue c’est-à-dire que la variable doit correspondre à la référence d’un objet nommé. La référence d’un objet nommé signifie qu’une variable désigne cet objet en mémoire.

Par exemple si on écrit:

ref readonly var circle = ref default(Circle); // ERREUR

ref default(Circle) ne correspond pas à la référence d’un objet nommé, il n’y a pas une variable appelée default(Circle) correspondant à un objet en mémoire. Il s’agit d’une valeur sans variable associée.

Utiliser ref readonly avec des objets de type valeur mutables est plus couteux en performance

Il existe une différence entre utiliser ref et ref readonly avec des structures mutables. Pour garantir que la structure mutable déclarée avec ref readonly n’est pas modifiée, le compilateur effectue une copie par valeur de la structure pour chaque déclaration d’une variable ref readonly (i.e. defensive copy). Cette copie est effectuée si la structure est mutable (c’est-à-dire qu’elle n’est pas déclarée avec readonly struct).

La copie de la structure peut être évitée si la structure est immutable en la déclarant avec readonly struct. Dans ce cas, le compilateur effectue des optimisations en évitant d’effectuer des copies par valeur à chaque déclaration d’une variable ref readonly.

Par exemple, la structure Circle est mutable:

var circle = new Circle();
ref readonly var circleRef = ref circle; // Une copie est effectuée

Si Circle est immutable:

struct readonly ImmutableCircle
{
  public int Radius { get; }

  void Circle(int radius)
  {
    this.Radius = radius;
  }
}

// ...
var circle = new ImmutableCircle(4);
ref readonly var circleRef = ref circle; // OK, le compilateur effectue une optimisation

Retour de fonction par référence

C# 7.0

A partir de C# 7.0, il est possible de retourner la référence d’un objet de type valeur. Toutefois tout n’est pas possible car techniquement il faut comprendre ce que signifie retourner la référence d’un objet de type valeur.

Ainsi, comme on a pu le voir précédemment, les arguments d’une fonction, ses variables locales et sa valeur de retour sont stockés dans la stack frame de la fonction. Cette stack frame disparaît lorsqu’on quitte la fonction. Par exemple, si on retourne une référence d’une variable locale de type valeur, après exécution de la fonction la référence ne correspondra plus à l’objet retourné puisque la stack frame de la fonction est perdue.

Retourner une référence vers un objet de type valeur est possible si la référence reste disponible à la sortie de la fonction. Ceci est possible si:

  • L’objet de type valeur est un membre d’un objet de type référence: dans ce cas il est stockée dans le tas managé et non dans la pile. La référence reste disponible à la sortie de la fonction.
  • L’objet de type valeur est statique: il n’est pas stocké dans la pile mais dans un tas particulier (loader heap ou high frequency heap). La référence vers l’objet statique de type valeur reste disponible à la sortie de la fonction.

Par exemple, si on considère l’exemple suivant:

public static ref Circle FindCircle(Circle[] circles, int circleIndex) 
{ 
  return ref circles[circeIndex]; 
} 

static void Main() 
{ 
  Circle[] circles = { 
    new Circle { Radius = 4 }, 
    new Circle { Radius = 2 } 
  }; 
 
  ref Circle foundCircle = ref FindCircle(circles, 1); 
  Console.WriteLine(foundCircle.Radius); // 2 
  foundCircle.Radius = 1; 
  Console.WriteLine(circles[1].Radius); // 1 
} 

C’est bien l’objet se trouvant dans le tableau circles qui est modifié et non une copie de l’objet. En effet, le retour de FindCircle() est une référence d’un objet dans le tableau circles.

Liste générique et LINQ

Dans l’exemple plus haut, l’utilisation du tableau Circle[] n’est pas anodine car cette structure permet de manipuler reéllement une référence de l’objet dans le tableau. Si on utilise une liste ou LINQ on manipulera une copie de l’objet dans la liste et non l’objet se trouvant dans la liste:

public static ref Circle FindCircle(List circles, int circleRadius) 
{ 
  return ref circles.First(c => c.Radius.Equals(circleRadius)); // ERROR: 
  // An expression cannot be used in the context because it may not be passed or 
  // returned by reference 
} 

Dans cet exemple, on obtient une erreur de syntaxe car circles.First() effectue une copie de l’objet et ne permet pas de récupérer une référence de l’objet dans la structure.

Pas de référence nulle

Il n’existe pas de référence nulle vers un objet de type valeur, on ne peut donc pas écrire une fonction de ce type:

public static ref Circle FindCircle(Circle[] circles, int circleRadius) 
{ 
  for (int i = 0; i < circles.Length; i++) 
  { 
    ref var foundCircle = ref circles[i]; 
    if (circles[i].Radius.Equals(circleRadius)) 
      return ref foundCircle; 
  } 

  return null; // ERROR: the return expression must be of type 'Circle' 
  // because the method returns by reference. 
} 

La solution peut consister à définir en avance une objet nul pour l’utiliser si le recherche échoue:

public static ref Circle FindCircle(Circle[] circles, int circleRadius, ref Circle notFoundCircle) 
{ 
  for (int i = 0; i < circles.Length; i++) 
  { 
    ref var foundCircle = ref circles[i]; 
    if (circles[i].Radius.Equals(circleRadius)) 
      return ref foundCircle; 
  } 

  return ref notFoundCircle; 
} 

L’appel peut se faire de cette façon:

static void Main() 
{ 
  Circle[] circles = { 
    new Circle { Radius = 4 }, 
    new Circle { Radius = 2 } 
  }; 

  var notFoundCircle = new Circle { Radius = 0 }; 
  ref var notFoundCircleRef = ref notFoundCircle; 

  ref Circle foundCircle = ref FindCircle(circles, 6, ref notFoundCircleRef); 
  if (foundCircle.Equals(notFoundCircleRef)) 
    Console.WriteLine("Not found"); // Not found 
}

Utilisation d’un objet statique

Un objet de type valeur stocké dans une variable statique se trouve techniquement dans un tas particulier (loader heap ou high frequency heap). Il est donc possible d’utiliser une référence vers cet objet statique sans se préoccuper de la durée de vie de la stack frame d’une fonction.

Par exemple:

private static Circle mediumCircle; 

public static ref Circle GetMediumCircle() 
{ 
  return ref mediumCircle; 
} 

static void Main() 
{ 
  mediumCircle = new Circle { Radius = 5 }; 
  ref var foundCircle = ref GetMediumCircle(); 
} 

L’objet mediumCircle n’est pas stocké dans la pile de la fonction GetMediumCircle(), on peut donc retourner une référence vers cet objet.

Utilisation d’un membre d’un objet de type référence

Un objet de type valeur étant un membre d’un objet de type référence est stocké dans le tas managé. On peut donc utiliser une référence vers cet objet de type valeur en retour d’une fonction.

Par exemple:

internal class CircleRepository 
{ 
  public Circle InnerCircle; 
} 

public static ref Circle SetNewRadius(CircleRepository circleRepository, int newRadius) 
{ 
  ref var circle = ref circleRepository.InnerCircle; 
  circle.Radius = newRadius; 
  return ref circle; 
} 

static void Main() 
{ 
  var circleRepo = new CircleRepository{ 
    InnerCircle = new Circle { Radius = 4 } 
  }; 

  ref var updatedCircle = ref SetNewRadius(circleRepo, 2); 
  Console.WriteLine(circleRepo.InnerCircle.Radius); // 2 
} 

Etant donné que InnerCircle est membre de la classe CircleRepository, même s’il s’agit d’un objet de type valeur, il est stocké dans le tas managé. On peut donc manipuler cet objet par référence et l’utiliser en retour de la fonction SetNewRadius().

Si on utilise un liste générique d’objet de type valeur, on ne peut pas extraire de la liste des éléments sans effectuer de copie par valeur.

Retour de fonction par référence en lecture seule

C# 7.2

A partir de C# 7.2, on peut indiquer qu’un objet retourné par une fonction par référence est en lecture seule en utilisant le type de retour ref readonly T:

ref readonly Circle GetCircle();

Le but d’avoir un objet retournée par référence en lecture seule est de sécuriser le code et éviter des modifications involontaires d’un objet.
Par exemple, si on retourne par référence le membre d’une classe, il est possible de modifier directement ce membre à l’extérieur de la classe, ce qui peut mener à un defaut d’encapsulation. Empêcher la modification du membre en utilisant sa référence permet d’éviter des erreurs.

L’utilisation de ref readonly en retour d’une fonction est simple: tous les objets pour lesquels on peut effectuer un retour par référence avec ref peuvent être retournés par référence en lecture seule:

class CircleRepository
{
  private Circle InnerCircle;

  ref readonly Circle GetCircle()
  {
    return ref this.InnerCircle; // ATTENTION: return ref et non return ref readonly
  }
}

L’inverse n’est pas possible: un objet retourné par référence en lecture ne peut pas être affecté à un variable qui n’est pas en lecture seule:

var circleRepo = new CircleRepository{ InnerCircle = new Circle() };
ref readonly circleRef = ref CircleRepo.GetCircle(); // OK
ref circleRef = ref CircleRepo.GetCircle(); // ERREUR
L’utilisation de ref readonly avec des objets de type valeur mutables entraîne un coût en performance

Comme les variables locales ref readonly, si ref readonly est utilisé avec une objet de type valeur mutable, le compilateur effectue des copies (i.e. defensive copy) pour préserver l’objet d’origine. Pour éviter ces copies, il est préférable que l’objet soit immutable par exemple en utilisant readonly struct.

Utiliser ref avec l’opérateur ternaire

C# 7.2

L’opérateur ternaire permet d’écrire des expressions du type:

<condition> ? <code si condition vraie> : <code si condition fausse> 

On peut effectuer des initialisations de ce type:

Circle[] circules = { 
  new Circle{ Radius = 4 }, 
  new Circle{ Radius = 2 }, 
}; 

var firstCircle = circles.First(); 
var bigCircle = foundCircle.Radius > 2 ? circles[0] : circles[1]; 

Dans la dernière ligne, des copies par valeur des objets sont effectuées car Circle est un objet de type valeur.

Pour effectuer des copies par référence, il est possible à partir de C# 7.2 d’utiliser le mot-clé ref avec l’opérateur ternaire:

ref var bigCircle = ref (foundCircle.Radius > 2 ? ref circles[0] : ref circles[1]); 

L’affectation est, ainsi, effectuée par référence.

Mot-clé in

C# 7.2

Lors d’appel de fonction, le mot-clé ref autorise le passage en argument d’objets de type valeur par référence. La modification de l’objet passé en argument est possible dans le corps de la fonction appelée. Comme pour ref, le mot-clé in permet d’indiquer qu’un objet de type valeur doit être passé par référence toutefois il interdit la modification de l’argument dans le corps de la fonction.

Par rapport à ref, les avantages à utiliser in sont les suivants:

  • On indique explicitement que l’objet ne pourra pas être modifié dans le corps de la fonction.
  • Le compilateur optimise le code généré en sachant que l’objet n’est pas modifié dans le corps de la fonction. Ainsi l’appel à la fonction est effectué de façon plus rapide qu’avec ref.

Par exemple, si on considère la classe Circle et la fonction suivante

struct Circle  
{ 
  public int Radius { get; set; } 

  public void SetNewRadius(int newRadius)  
  { 
    this.Radius = newRadius; 
  } 
} 

private static void ChangeRadius(int newRadius, ref Circle circle) 
{ 
  circle.Radius = newRadius; 
} 

Si on effectue l’appel suivant:

var circle = new Circle{ Radius = 4 }; 
Console.WriteLine(circle.Radius); // 4 
ChangeRadius(2, ref circle); // circle est passé par référence 
Console.WriteLine(circle.Radius); // 2 

Il n’y a pas de copie de circle lors du passage en argument quand on appelle ChangeRadius(). La même instance est passée en argument et modifiée dans le corps de ChangeRadius().

Si on modifie l’implémentation de ChangeRadius():

private static void ChangeRadius(int newRadius, in Circle circle) 
{ 
  // circle.Radius = newRadius; // ERREUR car on ne peut pas modifier circle dans le corps de la méthode 
  circle.SetNewRadius(newRadius); // Pas d'erreur 
  Console.WriteLine(circle.Radius);  
} 

Si on effectue l’appel suivant:

var circle = new Circle{ Radius = 4 }; 
Console.WriteLine(circle.Radius); // 4 
ChangeRadius(2, circle); // circle est passé par référence 

// Dans le corps de ChangeRadius(), Console.WriteLine(circle.Radius) affiche 4 
Console.WriteLine(circle.Radius); // 4 
Circle.SetNewRadius(3); 
Console.WriteLine(circle.Radius); // 3 

Avec in, l’argument circle est passé par référence toutefois le compilateur effectue des optimisations en sachant que circle ne peut être modifié dans le corps de la fonction. circle.SetNewRadius() ne modifie pas l’objet circle. Le résultat est toujours 4 quand on essaie de modifier circle à l’intérieur de ChangeRadius().

Appel à une méthode avec in

Quand un argument comporte le modificateur in, si la méthode ne comporte pas de surcharge, il est possible d’effectuer l’appel avec ou sans in. Si in est omis, il est considéré comme implicite et le comportement est le même que l’appel contenant in.

Par exemple dans le cas de l’exemple précédent, les appels suivants ont le même comportement:

ChangeRadius(2, circle); // Passage par référence implicite 
ChangeRadius(2, in circle); // Passage par référence explicite 

Surcharge des méthodes avec in

Les surcharges de méthode avec ref et in ne sont pas possibles, une seule surcharge est possible.

Par exemple, si on crée 2 méthodes de ce type:

private static void ChangeRadius(int newRadius, ref Circle circle) { ... } 
private static void ChangeRadius(int newRadius, in Circle circle) { ... } // ERREUR: généré une erreur de compilation 

Il est possible d’avoir 2 surcharges avec et sans in:

private static void ChangeRadius(int newRadius, Circle circle) { … } 
private static void ChangeRadius(int newRadius, in Circle circle) { … } // OK 

Dans ce dernier cas, l’utilisation de in lors des appels est importante puisqu’elle va permettre d’indiquer quelle surcharge sera utilisée:

ChangeRadius(2, circle); // la surchage ChangeRadius(int newRadius, Circle circle) est appelée => passage par valeur 
ChangeRadius(2, in circle); // la surchage ChangeRadius(int newRadius, in Circle circle) est appelée => passage par référence 
Utiliser in peut être plus coûteux en performance que ref

Outre la différence fonctionnelle entre in et ref (ref autorise la modification de l’argument alors que in ne l’autorise), il existe une différence en terme de performance: suivant la façon dont on l’utilise in peut être beaucoup plus coûteux que ref lors d’appel de méthodes.

Pour garantir qu’un objet passé en paramètre d’une méthode est en lecteur seule, le compilateur effectue une copie par valeur de cet objet (i.e. defensive copy). Cette copie sera temporaire et réservé à l’utilisation du paramètre dans la méthode.

Pour s’en convaincre, il suffit d’exécuter le code suivant:

public struct Circle 
{ 
  public Circle(int radius) 
  { 
    this.Radius = radius; 
  } 
 
  public int Radius { get; private set; } 

  public void UpdateRadius(int newRadius) 
  { 
    this.Radius = newRadius; 
  } 
} 

public static void ChangeRadius(int newRadius, in Circle circle) 
{ 
  circle.UpdateRadius(newRadius); 
  Console.WriteLine(circle.Radius); 
} 

Ensuite, on effectue les appels suivants:

var circle = new Circle(4); 
ChangeRadius(3, in circle); // 4 
Console.WriteLine(circle.Radius); // 4 

Ainsi bien dans le corps de la méthode ChangeRadius() qu’à l’extérieur la valeur de circle.Radius est 4 même si on tente de modifier la valeur de Radius. La raison est que in entraîne la création d’une copie temporaire pour éviter d’affecter l’instance d’origine de Circle.

La conséquence de cette copie est une dégradation des performances par rapport à l’utilisation de ref. ref permet d’utiliser une référence toutefois il n’y a pas de copie dans une variable temporaire des arguments de la méthode.

Pour permettre au compilateur d’effectuer des optimisations quand on utilise in, il faut que la structure de l’objet passé en paramètre soit immutable en utilisant readonly:

public readonly struct Circle 
{ 
  // ... 
} 

Pour davantage de détails, voir readonly struct.

Pour résumer…

Le mot-clé ref permet d’effectuer des manipulations d’objets de type valeur par référence:

  • Pour un objet de type valeur, on manipule une référence vers cet objet (plus précisément on utilise un objet ref qui pointe vers l’objet de type valeur).
  • Pour un objet de type référence, on manipule une référence de la référence vers l’objet (on utilise un objet ref qui pointe vers la référence de l’objet de type référence, la référence d’un objet de type référence étant elle-même un objet de type valeur).
Syntaxe Remarques
Passage en argument
par référence
(ref)
void MethodName(ref <type> argument)
{ ... }
Par exemple:

private static void ChangeRadius(int newRadius, 
    ref Circle circle)
{
  circle.Radius = newRadius;
}
Manipulation
d’une variable locale
(ref)
ref var refVariable = 
  ref <value variable>;
Par exemple:

Circle circle = new Circle();
ref Circle circleRef = ref circle;

Réaffectation d’une référence:

// NE PAS OUBLIER ref
<variable par référence 2 > = 
  ref <variable par référence 1> 

Par exemple:

Circle firstCircle = new Circle ...
// ...
// Affectation d’une référence
secondCircleRef = ref firstCircleRef; 
// Affectation par valeur, une copie est effectuée
secondCircleRef = firstCircleRef; 
Manipulation
d’une variable locale
en lecture seule
(ref readonly)
ref readonly var refVariable = 
  ref <value variable>;
Toutes les variables ref peuvent être affectées en readonly.
L’affectation d’un membre sur une variable ref en lecture seule n’est pas possible.

Par exemple:

Circle circle = new Circle { Radius = 2; }
// Référence en lecture seule à partir 
// d’un objet de type valeur
ref readonly readOnlyCircleRef = ref circle; 
readOnlyCircleRef.Radius = 4; // ERREUR

// Référence en lecture/écriture
ref var circleRef = ref circle; 
// Référence en lecture seule à partir 
// d’une autre référence
ref readonly otherReadOnlyCircleRef = ref circleRef; 

ATTENTION: Pour éviter les defensive copies, il est préférable que la structure soit immutable (cf. readonly struct).

Retour de fonction
par référence
(return ref)
ref <type> FunctionName()
{
  // ...
  return ref <variable>;
}
La variable retournée par référence doit être accessible à l’extérieur de la stack frame correspondant à la fonction, il peut s’agir:

  • D’un membre d’un objet de type référence:
    class CircleWrapper
    {
      private Circle InnerCircle = 
        new Circle { Radius = 2 };
    
      public ref Circle GetCircle()
      {
        return ref this.InnerCircle; // OK l’objet 
        // est membre d’un objet de type référence
      }
    }
  • D’un objet de type valeur statique:
    static Circle staticCircle = new Circle { Radius = 2};
    ref Circle GetCircle()
    {
      return ref staticCircle; // OK l’objet retourné 
      // est statique
    }
  • D’un objet stocké dans un tableau:
    ref Circle FindCircle(Circle[] circles, 
      int circleIndex)
    {
      return ref circles[circleIndex]; // OK l’objet 
      // appartient à un tableau
    }

Par contre, on ne peut pas retourner une variable locale d’une fonction:

ref Circle GetCircle()
{
  Circle localCircle = new Circle { Radius = 4 };
  return ref localCircle; // ERREUR, l’objet est perdu 
  // à la sortie de la stack frame
}
Retour de fonction
par référence
en lecture seule
(return ref)
ref readonly <type> FunctionName()
{
  // ...
  return ref <variable>;
}
Même restriction que pour un retour de fonction par référence simple.
Un membre en readonly doit obligatoirement être retourné en readonly:

class CircleWrapper
{
  private readonly Circle InnerCircle = 
    new Circle { Radius = 2 };

  // OK retour en readonly
  public readonly ref Circle GetReadOnlyCircle() 
  {
    return ref this.InnerCircle;
  }

  // ERREUR le retour doit être en readonly
  public ref Circle GetCircle()
  {
    return ref this.InnerCircle;
  }
}

L’affectation dans une variable d’une fonction en readonly doit obligatoirement être en readonly.

private static Circle staticCircle = 
  new Circle { Radius = 2};
public static ref readonly Circle GetCircle()
{
  return ref staticCircle;
}

// ...
ref readonly var readOnlyCircle = ref GetCircle(); // OK
ref var readOnlyCircle = ref GetCircle(); // ERREUR

ATTENTION: Pour éviter les defensive copies, il est préférable que la structure soit immutable (cf. readonly struct).

Indiquer qu’un argument
de méthode est en
lecture seule avec in
void MethodName(in <type> argument) 
{ ... }
L’affectation d’un membre d’un argument avec in n’est pas possible.
Par exemple:

struct Circle
{
  public int Radius;

  public void SetRadiusFromInside(int newRadius)
  {
    this.Radius = newRadius;
  }
}

static void ChangeRadiusFromOutside(in Circle circle, 
  int newRadius)
{
  // ERREUR à cause de in
  circle.Radius = newRadius; 

  // Pas d’erreur mais la valeur n’est pas modifiée 
  // à cause de la defensive copy
  circle.SetRadiusFromInside(newRadius); 
  // ATTENTION: il faut éviter de modifier 
  // la valeur de l’argument dans le corps de la méthode
}

ATTENTION: Pour éviter les defensive copies, il est préférable que la structure soit immutable (cf. readonly struct).

Références

Leave a Reply