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

Confirmé

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.

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 à la référence de 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 une référence 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 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.

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 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 newCircle = ref circle; // On considère la référence de l’objet 
circle.Radius = 2; 
Console.WriteLine(circle.Radius); // 2 
Console.WriteLine(newCircle.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 circle 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:

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

Même si firstCircle et secondCircle 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):

secondCircle = ref firstCircle; // 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 thirdCircle = ref firstCircle; 

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.

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. 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 
{ 
    // ... 
} 

Références
Share on FacebookShare on Google+Tweet about this on TwitterShare on LinkedInEmail this to someonePrint this page

Leave a Reply