Membre d’une structure en lecture seule avec readonly (C# 8.0)

Cet article fait partie d’une série d’articles sur les apports fonctionnels de C# 8.0.

Cette fonctionnalité permet d’indiquer que des membres d’une structure ne modifient aucune données membres de cette structure. On peut ne pas comprendre au premier abord l’utilité de cette fonctionnalité car d’autres fonctionnalités déjà existantes (comme readonly struct apparue en C# 7) permettent déjà de rendre une structure immutable. Pour comprendre son intérêt, il faut avoir en tête quelques éléments:

  • Une structure struct est un objet de type valeur stocké le plus souvent sur la pile toutefois elle peut être stockée dans le tas managé si elle satisfait une interface ou si elle est le membre d’un objet de type référence.
  • Une structure ref struct est un objet de type valeur toujours stocké sur la pile.
  • Les affectations ou les passages en argument de méthode d’une structure entraînent une copie par valeur de l’objet. Cette copie peut avoir un impact sur les performances durant l’exécution dans le cas où certaines opérations sont effectuées fréquemment et si la structure contient beaucoup de membres.
  • L’utilisation des mot-clés in ou ref (apparus en C# 7) permettent de manipuler des structures par référence et ainsi éviter des copies lors des affectations ou des passages en argument si la structure est immutable. Dans le cas où la structure n’est pas immutable, le runtime peut effectuer des defensive copies (on explique par la suite ce qu’est une defensive copy) dégrandant elles-aussi les performances.
  • Pour qu’une structure soit immutable par syntaxe, on peut utiliser les mots-clés readonly struct (ou readonly ref struct dans le cas d’une ref struct).

Le gros inconvénient de readonly struct et readonly ref struct est qu’ils rendent la structure complètement immutable et qu’il n’y a pas d’autre granularité possible. C# 8.0 permet d’utiliser readonly à un niveau plus fin en autorisant à l’appliquer sur une méthode membre, des propriétés ou des index.

readonly ne s’applique qu’aux objets struct et ref struct

On peut utiliser le mot-clé readonly sur des méthodes, sur des propriétés ou sur des index d’une structure ou d’un objet de type ref struct pour indiquer au compilateur que l’opération ne modifie pas la structure. Il n’est pas possible d’appliquer ce mot-clé dans le cas d’une classe.

En effet, readonly permet de se prémunir des defensive copies qui peuvent se produire dans le cas d’objet de type valeur comme les structures. Les classes sont des objets de type référence stockés dans le tas managé et manipulés avec des références. Elles ne sont pas concernées par les defensive copies.

A l’opposé, readonly au niveau d’une donnée membre peut s’appliquer dans le cas d’une structure et d’une classe.

Utilisation de readonly sur les membres d’une structure

readonly sur des méthodes membres

readonly peut être appliquer sur des méthodes membres d’une structure.

Par exemple, si on considère la structure suivante:

public struct Circle
{
  public int radius;

  public Circle(int radius)
  {
    this.radius = radius;
  }

  // Modifie une donnée membre
  public void UpdateRadius(int newRadius)
  {
    this.radius = newRadius;
  }

  // Ne modifie pas la structure
  public int AddToRadius(int number)
  {
    return this.radius + number;
  }
}

On souhaite pouvoir utiliser cette structure de façon à ce qu’elle soit immutable et en évitant dans certains cas les defensive copies:

  • Si on appelle seulement AddToRadius(), la structure reste immutable toutefois il peut y avoir quand même des defensive copies car le compilateur ne sait pas si AddToRadius() réellement ou non la structure.
  • Si on rend la structure immutable en la déclarant avec readonly:
    public readonly struct Circle
    { ... }
    

    Il y aura une erreur de compilation car l’affectation dans UpdateRadius() n’est plus possible.

Permettre de rajouter readonly au niveau des fonctions membres, des propriétés ou des index permet d’indiquer l’aspect immutable d’une opération sur une structure à un niveau plus fin pour éviter que toute la structure soit immutable.

Dans le cas de l’exemple précédent, si on modifie le code de cette façon:

public struct Circle
{
  public int radius;

  public Circle(int radius)
  {
    this.radius = radius;
  }

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

  public readonly int AddToRadius(int number)
  {
    return this.radius + number;
  }
}

Si on exécute seulement la fonction AddToRadius(), la structure est immutable et il n’y a pas de defensive copies. On peut, toutefois, effectuer des opérations rendant la structure mutable avec UpdateRadius().

readonly sur des propriétés

On peut appliquer readonly sur des propriétés de façon à indiquer que l’utilisation de la propriété ne modifie pas la structure.

Par exemple, si on considère l’exemple précédent de la structure Circle, on peut appliquer readonly au niveau d’un accesseur:

  • En lecture seulement:
    public struct Circle
    {
      private int radius;
    
      public int Radius
      {
        readonly get => this.radius;
        set => this.radius = value;
      }
    
      // ...
    }
    
  • En écriture seulement:
    public struct Circle
    {
      private int radius;
    
      public int Radius
      {
        get => this.radius;
        readonly set => Console.WriteLine(value);
      }
    
      // ...
    }
    

    Une erreur survient à la compilation si on applique une opération en écriture avec readonly set => ....

  • En lecture et en écriture en mettant readonly au niveau de la propriété plutôt que des accesseurs:
    public struct Circle
    {
      private int radius;
    
      public readonly int Radius
      {
        get => this.radius;
        set => Console.WriteLine(value);
      }
    
      // ...
    }
    

readonly au niveau d’un index

readonly peut être appliqué au niveau d’un index de la même façon que pour les propriétés. Si on considère la structure suivante:

public struct Numbers
{
  private int[] numbers;

  private Numbers(int count)
  {
    this.numbers = new int[count];
  }

  public int this[int i]
  {
    get => this.numbers[i];
    set => this.numbers[i] = value;
  }
}

On peut indiquer que l’utilisation de l’index ne modifie pas la structure:

  • En lecture seulement:
    public int this[int i]
    {
      readonly get => this.numbers[i];
      set => this.numbers[i] = value;
    }
    
  • En écriture seulement:
    public int this[int i]
    {
      get => this.numbers[i];
      readonly set => this.numbers[i] = value;
    }
    
  • En lecture et en écriture:
    public readonly int this[int i]
    {
      get => this.numbers[i];
      set => this.numbers[i] = value;
    }
    
Ne pas confondre readonly et ref readonly

Même si le mot-clé readonly est utilisé dans les 2 cas, readonly utilisé pour indiquer qu’une opération ne modifie pas une structure et ref readonly sont 2 notions différentes:

  • readonly sur les membres d’une structure permet d’éviter les defensive copies lors d’opérations appliquées à la structure.
  • ref readonly permet d’indiquer qu’un objet de type valeur est manipulé par référence et non par valeur.

Par exemple, si on considère la structure suivante:

public struct IntRefWrapper
{
  private int[] numbers;

  public IntRefWrapper(int count)
  {
    this.numbers = new int[count];
  }

  public readonly ref readonly int GetIntByRef(int index)
  {
    return ref this.numbers[index];
  }
}

Dans la fonction GetIntByRef(), on utilise les 2 notions:

  • public readonly permet d’indiquer que la méthode ne modifie pas la structure.
  • ref readonly int indique le type de retour de la fonction est un objet de type int retourné par référence.

Précisions sur les defensive copies

Pour se rendre compte des defensive copies, on peut considérer l’exemple de la structure suivante:

public struct Circle
{
  public int radius;

  public Circle(int radius)
  {
    this.radius = radius;
  }

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

Cette structure est mutable à cause de la méthode UpdateRadius() qui permet de modifier la donnée membre radius.

Si on considère la méthode suivante:

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

Cette méthode utilise le paramètre Circle circle avec le mot-clé in de façon à ce que ce soit une référence du paramètre en lecture seule qui soit utilisée et éviter une copie par valeur de l’objet (pour plus de détails sur in voir Manipuler des objets de type valeur par référence). Le gros inconvénient de in est qu’il entraîne un defensive copy, on peut s’en rendre compte si on exécute le code suivant:

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

radius contient toujours 4 car in impose que circle dans ChangeRadius() soit en lecture seule. Le compilateur effectue une defensive copy pour assurer que circle n’est effectivement pas modifié dans le corps de ChangeRadius(). On modifie le code de la structure Circle pour afficher l’adresse de l’objet:

public struct Circle
{
  // ...

  public void UpdateRadius(int newRadius)
  {
    // On commente volontairement cette ligne de façon à rendre la structure immutable
    //this.radius = newRadius;

    // Permet d’afficher l’adresse mémoire de l’instance
    unsafe
    {
      fixed (Circle* ptr = &this)
      {
        Console.WriteLine(new IntPtr(ptr));
      }
    }
  }
}

Si on exécute le même code, on s’aperçoit que l’adresse est différente à cause de la defensive copy:

var circle = new Circle(4);

// Permet d’afficher l’adresse mémoire de circle
unsafe
{
  fixed (Circle* ptr = &circle)
  {
    Console.WriteLine(new IntPtr(ptr));
  }
}

ChangeRadius(3, circle);
Console.WriteLine(circle.radius);

Le résultat est:

347086513304
347086512744
4
4

L’adresse est différente à cause de la copie même si la structure est maintenant immutable. Pour empêcher cette copie, on peut indiquer au compilateur que la structure est immutable en modifiant sa déclaration en readonly struct:

public readonly struct Circle
{
  // ...
}

Si on re-éxécute le code, les adresses sont maintenant identiques car il n’y a plus de defensive copy:

950267405944
950267405944
4
4

Comme on l’a indiqué plus haut, on peut éviter de rendre toute la structure immutable avec readonly struct. On peut se contenter d’indiquer au compilateur que l’appel à UpdateRadius() ne modifie pas la structure en rajoutant readonly au niveau de la fonction uniquement. L’implémentation de la structure devient:

public struct Circle
{
  public int radius;

  public Circle(int radius)
  {
    this.radius = radius;
  }

  public readonly void UpdateRadius(int newRadius)
  {
    //this.radius = newRadius;

    // Permet d’afficher l’adresse mémoire de l’instance
    unsafe
    {
      fixed (Circle* ptr = &this)
      {
        Console.WriteLine(new IntPtr(ptr));
      }
    }
  }
}

Si on exécute le code suivant:

ChangeRadius(3, circle);
Console.WriteLine(circle.radius);

Le résultat est:

950267448258
950267448258
4
4

Les adresses mémoire de la structure sont les mêmes avant et après exécution de la méthode UpdateRadius(). Dans ce cas là, il n’y a pas non plus de defensive copy. L’utilisation de readonly au niveau de la méthode permet d’éviter de rendre toute la structure immutable. On peut, ainsi, implémenter dans la même structure:

  • des méthodes qui ne modifient pas de données membres qui seront réservées aux endroits critiques où il ne faut pas que le runtime effectue des defensive copies.
  • d’autres méthodes modifiant éventuellement des données membres dans la structure.

Associer ces 2 types de méthodes n’est pas possible avec une readonly struct.

readonly protège seulement des affectations

Utiliser readonly au niveau des membres d’une structure permet d’empêcher les nouvelles affectations de données membres dans la structure. Si on tente d’effectuer ce type d’affectation, une erreur de compilation surviendra. En revanche, si on tente de modifier une donnée membre sans effectuer d’affectation, il n’y aura pas d’erreur de compilation.

Si on considère le code suivant proche de l’exemple précédent:

public struct Numbers
{
  private int id; // Objet de type valeur
  private List<int> numbers; // Référence vers un objet de type référence

  public Numbers(int id, IEnumerable<int> numbers)
  {
    this.id = id;
    this.numbers = new List<int>(numbers);
  }

  public readonly int ID => this.id;
  public readonly IList<int> numbers => this.numbers;

  public readonly void AddNumber(int newNumber)
  {
    this.numbers.Add(newNumber);
  }

  public void UpdateID(int newId)
  {
    this.id = newId,
  }
}

// ...
public static void ChangeIDAndAddNumber(in Numbers numbers, int newId, int newNumber)
{
  numbers.UpdateID(newId);
  numbers.AddNumber(newNumber);
  Console.WriteLine($" ID: {numbers.ID}; item count: {numbers.Items.Count()}");
}

Dans l’implémentation de la structure, on peut constater que la signature public readonly void AddNumber() n’empêche pas de modifier le membre numbers avec this.numbers.Add(newNumber). Il n’y a pas d’erreur de compilation.

Si on exécute ce code:

var numbers = new Numbers(1, new int[] { 1, 2, 3});
Console.WriteLine($" ID: {numbers.ID}; item count: {numbers.Items.Count()}");
ChangeIDAndAddNumber(numbers, 2, 4);

Le résultat est:

ID: 1; item count: 3
ID: 1; item count: 4

Le résultat est similaire à l’exemple de la partie précédente. La defensive copy qui est effectuée dans la méthode ChangeIDAndAddNumber() entraîne que l’objet d’origine n’est pas modifié, la valeur de ID est toujours la même. En revanche le nombre d’éléments dans la liste numbers est différent car cette liste est modifiée.

Ainsi, readonly ne sert à se prémunir que des defensive copies qui peuvent se produire dans le cas d’objets de type valeur. Le membre numbers dans la structure Numbers est une référence vers une liste qui est un objet de type référence. La liste est stockée dans le tas managée mais la référence numbers (la référence est un objet de type valeur) est stockée dans la pile comme la structure Number. La liste peut être modifiée dans le tas managée toutefois la référence dans la structure n’est pas modifiée. C’est la raison pour laquelle malgré la defensive copy, le nombre d’éléments de la liste change.

Pour résumer, readonly au niveau des membres d’une structure permet d’empêcher de modifier les membres de la structure par affectation toutefois, les membres de type référence peuvent être modifiés.

Leave a Reply