Améliorations concernant les variables ref et les ref struct (C# 13)

Cet article fait partie d’une série d’articles sur les nouveautés fonctionnelles de C# 13.

La version C# 13 introduit plusieurs améliorations concernant les ref struct et les variables de référence ref:

  • Les ref struct ont désormais la capacité d’implémenter des interfaces,
  • Une nouvelle contrainte de type générique permet l’utilisation d’objets ref struct,
  • Les variables de référence ref peuvent être implémentées dans les méthodes avec yield return (itérateurs) ainsi que dans les méthodes async,
  • L’utilisation d’unsafe est maintenant autorisée dans les méthodes employant yield et les méthodes async.
Rappels importants sur les ref struct

Le tas managé (managed heap) héberge les objets de type référence (tels que les classes), tandis que la pile (stack) stocke généralement les objets de type valeur (comme les struct). La manipulation d’objets de type référence s’effectue via des références vers ces objets. En revanche, pour les objets de type valeur, leur manipulation peut entraîner des copies par valeur selon la méthode employée.

Bien qu’une structure soit un objet de type valeur habituellement stockée dans la pile (stack), certaines situations particulières modifient ce comportement:

  • Une structure déclarée comme objet statique sera allouée dans un tas spécifique (loader heap ou high frequency heap).
  • Lorsqu’une structure constitue un membre d’un objet de type référence, son stockage s’effectue dans le tas managé (managed heap).
  • Le mécanisme de boxing peut également conduire au stockage d’une structure dans le tas managé.

La déclaration ref struct (disponible depuis C# 7.2) permet de créer une struct exclusivement utilisable dans la pile. Cette déclaration impose des restrictions supplémentaires à une struct afin de garantir son stockage permanent dans la pile. Ces restrictions entraînent toutefois certaines limitations d’utilisation:

  • L’impossibilité d’utiliser une ref struct au sein d’un tableau.
  • L’interdiction pour une ref struct d’être membre d’une classe.
  • L’impossibilité d’utiliser une ref struct comme argument dans une expression lambda.

Depuis C# 13/.NET 9, plusieurs limitations existantes depuis l’apparition des ref struct (C# 7.2) ont été levées:

  • Une ref struct peut désormais satisfaire une interface
  • Une ref struct peut servir d’argument pour un type générique
  • L’utilisation de ref struct dans une méthode async et dans un itérateur est maintenant autorisée.

L’utilisation de ref struct vise l’amélioration des performances en favorisant la manipulation de l’objet dans la pile plutôt que dans le tas managé. De plus, lors de la manipulation d’une ref struct, l’emploi de variables de référence ref est recommandé pour éviter les copies par valeur:

  • Passage en paramètre de méthode:
    Soit la ref struct:

    ref struct RefStruct { }
    

    et la méthode:

    private void PassByRef(ref RefStruct refStruct)
    {
    
    }
    

    Le passage en paramètre s’écrit:

    var refStruct = new RefStruct();
    PassByRef(ref refStruct);
    
  • Retour de fonction:
    Soit la méthode:

    private ref RefStruct ReturnByRef(ref RefStruct refStruct)
    {
      return ref refStruct;
    }
    

    L’appel peut s’effectuer ainsi:

    ref RefStruct returnedRef = ref ReturnByRef(ref refStruct);
    
  • Manipulation de variable locale:
    var refStruct = new RefStruct();
    ref RefStruct otherRefStruct = ref refStruct;
    

Implémentation d’interfaces par les ref struct

Depuis C# 13, les objets ref struct peuvent implémenter des interfaces, mais la conversion (cast) d’une variable de type ref struct vers une interface demeure impossible. Cette restriction persiste pour empêcher le boxing.

Considérons l’interface suivante:

internal interface IRectangle
{
  int Length { get; set; }
  int Width { get; set; }
  int GetArea();
}

L’implémentation dans une ref struct est possible:

internal ref struct Rectangle : IRectangle
{
  public int Length { get; set; }
  public int Width { get; set; }

  public Rectangle(int length, int width)
  {
    Length = length;
    Width = width;
  }

  public int GetArea()
  {
    return Length * Width;
  }
}

Cependant, le cast reste interdit:

Rectangle rect = new Rectangle(10, 20);
IRectangle iRect = rect;   // KO

De manière similaire, le passage d’une ref struct en argument de fonction sous la forme d’une interface n’est pas autorisé, par exemple si on considère une fonction comme celle-ci:

private int CalculateArea(IRectangle rectangle)
{
  return rectangle.GetArea();
}

L’appel avec une ref struct échoue:

var rectangle = new Rectangle(3, 4);
int area = CalculateArea(rectangle); // KO

Comme mentionné précédemment, cette restriction s’explique par le fait que la conversion vers l’interface nécessite du boxing pour stocker l’objet dans le tas managé. Cette opération est impossible puisque les ref struct doivent exclusivement résider dans la pile. Cette limitation concerne uniquement les ref struct et ne s’applique pas aux struct classiques.

Par exemple avec la struct:

internal struct RectangleStruct : IRectangle
{
  // Même contenu que Rectangle
  // ...
}

Le code suivant fonctionne:

var rectangleStruct = new RectangleStruct();
IRectangle rect = rectangleStruct;
int area = rect.GetArea();

L’examen du code MSIL correspondant révèle l’exécution d’une opération de boxing:

IL_0000: nop
// RectangleStruct rectangleStruct = new RectangleStruct(3, 4);
IL_0001: ldloca.s 0
IL_0003: ldc.i4.3
IL_0004: ldc.i4.4
IL_0005: call instance void CS13.RectangleStruct::.ctor(int32, int32)
// IRectangle rectangle = rectangleStruct;
IL_000a: ldloc.0
IL_000b: box CS13.RectangleStruct
IL_0010: stloc.1
// int area = rectangle.GetArea();
IL_0011: ldloc.1
IL_0012: callvirt instance int32 CS13.IRectangle::GetArea()
IL_0017: stloc.2

Nouvelle contrainte de type générique pour les objets ref struct

Une contrainte existante pour les objets génériques permet d’exiger que le paramètre générique soit une struct:

public class ObjectExample<T> where T: struct
{
  // ...
}

Cette contrainte seule n’autorise que l’utilisation de paramètres génériques de type struct, excluant les ref struct. Pour inclure les ref struct, il faut employer la nouvelle contrainte allows ref struct. La documentation (cf. learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-13#allows-ref-struct) qualifie cette contrainte d’anti-contrainte, car elle n’ajoute pas de limitation supplémentaire sur le type du paramètre générique mais élargit plutôt les types acceptables.

Si on ajoute allows ref struct à l’objet précédent:

public class ObjectExample<T> where T: struct, allows ref struct
{
  // ...
}

En présence de contraintes multiples :

  • allows ref struct doit impérativement apparaître en dernière position dans la liste des contraintes.
  • allows ref struct est incompatible avec la contrainte class (imposant que le type soit une classe).

Exemple d’utilisation:

internal ref struct ShapeAreaCalculator<T> where T: IRectangle, allows ref struct 
{
  private readonly T innerShape;
  public ShapeAreaCalculator(T shape)
  {
    innerShape = shape;
  }
  public int CalculateArea()
  {
    return innerShape.GetArea();
  }
}

Bien-que la conversion de ref struct vers une interface reste impossible (comme vu précédemment), l’utilisation de la contrainte where T: IRectangle est autorisée:

Rectangle rect = new Rectangle(10, 20);
var shapeAreaCalculator = new ShapeAreaCalculator<Rectangle>(rect);
int area = shapeAreaCalculator.CalculateArea();

L’ajout de cette contrainte a permis à Microsoft d’étendre significativement la compatibilité des différents objets du framework .NET avec les ref struct. Par exemple, avant C# 13, l’utilisation de ref struct avec Func<T> était impossible :

Func<Rectangle, int> getShapeArea = (shape) => shape.GetArea();   // Possible à partir de C# 13

Microsoft a intégré la contrainte allows ref struct dans de nombreux objets du framework, notamment pour les Func<T>: src/libraries/System.Private.CoreLib/src/System/Function.cs#L9.

L’ajout de la contrainte allows ref struct entraîne des restrictions significatives. Par exemple, l’utilisation du paramètre générique comme donnée membre d’un objet impose à cet objet d’être une ref struct. Dans l’exemple précédent, l’objet ShapeAreaCalculator ne peut être ni une struct ni une classe:

internal class ShapeAreaCalculator<T> where T : IRectangle, allows ref struct   //  KO: impossible
{
  private readonly T innerShape;

  // ...
}

Une struct simple n’est également pas autorisée:

internal struct ShapeAreaCalculator<T> where T : IRectangle, allows ref struct   //  KO: impossible
{
  private readonly T innerShape;

  // ...
}

Un avantage majeur de la contrainte allows ref struct est de permettre l’utilisation de Span<T> et ReadOnlySpan<T> comme types de paramètres génériques (Span et ReadOnlySpan étant des ref struct).

Exemple:

internal ref struct ShapeCollectionAreaCalculator<T> where T : allows ref struct
{
  private readonly T innerCollection;
  private readonly int collectionSize;
  private readonly Func<T, int, int> getItemArea;

  public ShapeCollectionAreaCalculator(T collection, int collectionSize, Func<T, int, int> getItemArea)
  {
    this.innerCollection = collection;
    this.getItemArea = getItemArea;
  }

  public int CalculateArea()
  {
    int area = 0;
    for (int i = 0; i < collectionSize; i++)
    {
      area = +getItemArea(innerCollection, i);
    }
    return area;
  }
}

Utilisation de cet objet:

Span<RectangleStruct> shapes = stackalloc RectangleStruct[10];
Func<Span<RectangleStruct>, int, int> getShapeArea = (span, index) => span[index].GetArea();
var shapeCalculator = new ShapeCollectionAreaCalculator<Span<RectangleStruct>>(shapes, 10, getShapeArea);

Variables locales ref dans les méthodes avec yield et async

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

  • Pour un objet de type valeur: on manipule une référence vers cet objet (plus précisément, on utilise un objet ref pointant 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 pointant vers la référence de l’objet de type référence, cette dernière étant elle-même un objet de type valeur).

Davantage de détails ici : cdiese.fr/csharp7-value-type-object-by-reference-valeur-par-reference/#cs7-value_type_by_ref-sumup.

Avant C# 13.0, l’utilisation de variables locales dans une méthode employant yield ou dans une méthode async était interdite.

Variable locale ref dans une méthode avec yield

Considérons l’itérateur suivant:

internal class CustomIterator : System.Collections.IEnumerable
{
  private readonly CustomEnumerator enumerator;

  public CustomIterator(int max)
  {
    enumerator = new CustomEnumerator(max);
  }

  public System.Collections.IEnumerator GetEnumerator()
  {
    return enumerator;
  }
}

internal class CustomEnumerator : System.Collections.IEnumerator
{
  private readonly int max;
  private int current;

  public CustomEnumerator(int max)
  {
    this.max = max;
    current = -1;
  }

  public object Current => current;

  public bool MoveNext()
  {
    current++;
    return current < max;
  }

  public void Reset()
  {
    current = -1;
  }
}

Son utilisation avec yield return:

public System.Collections.IEnumerable RefLocalVariableInIterator()
{
  var customIterator = new CustomIterator(5);
  foreach (var item in customIterator)
  {
    yield return item;
  }
}

L’emploi de variables locales ref est désormais autorisé dans ce type de méthode :

public System.Collections.IEnumerable RefLocalVariableInIterator()
{
  var customIterator = new CustomIterator(5);
  int simpleSum = 0;

  // C# 13: ref in iterator local variable 
  ref int refSum = ref simpleSum;  // Variable locale déclarée par référence
  foreach (var item in customIterator)
  {
    simpleSum++;
    refSum = ref simpleSum;
    yield return item;
  }
}

Variable locale ref dans une méthode async

L’utilisation d’une variable locale ref dans une méthode async est maintenant possible, par exemple:

internal class AsyncAwaitDemo
{
  private int innerValue = 0;

  public async Task RunAsync()
  {
    int localValue = 0;
    ref int refValue = ref localValue;  // C# 13: ref local variable in async method
    ref int refInnerValue = ref innerValue;
    await Task.Delay(1000); // Asynchronously wait for 1 second
  }
}

Leave a Reply