Amélioration des structures (C# 10, C# 11)

Le but de cet article est d’indiquer les améliorations faites sur les objets structures (i.e. struct) en C# 10 et C# 11.

Dans un 1er temps, on rappelle les caractéristiques des structs. Ensuite, on indique quelles ont été les améliorations apportées aux structs par C# 10 et C# 11.

Rappels concernant les objets de type valeur

D’une façon générale, les types d’objet en C# peuvent être séparés en 2 familles:

  • Les objets de type référence: les variables d’objets de type référence contiennent des références vers les objets en mémoire. Ces variables contenant les références sont objets de type valeur. Ainsi lorsqu’on effectue une affectation d’une variable d’un objet de type référence vers une autre variable, la référence est dupliquée et copiée dans la nouvelle variable toutefois l’objet référencé n’est pas dupliqué.
    Parmi les objets de type référence, on peut trouver les classes, les interfaces, les tableaux, le type delegate et le type dynamic. Les objets de type référence dérivent de System.Object.
  • Les objets de type valeur: les variables d’objets de type valeur correspondent à la représentation de la valeur réelle de l’objet. L’affectation d’une variable d’un objet de type valeur vers une autre variable effectue une copie de la représentation de la valeur de l’objet.
    Les objets de type valeur sont les structs et les enums. Parmi les structs, on peut citer les tuples, booléens, les types intégrals (sbyte, byte, short, ushort, int, uint, long, ulong et char), les types à virgule flottante (float ou double) et decimal. Les objets de type valeur dérivent de System.ValueType qui dérive de System.Object.

Caractéristiques des objets de type valeur

Les caractéristiques essentielles des objets de type valeur sont qu’en tant que représentation de la valeur d’un objet, ils ne sont jamais nuls et les affectations de variables effectuent des copies des objets par valeur. Ainsi tous ces objets possèdent un constructeur par défaut qui va effectuer une initialisation à zéro des différentes données membres que constituent l’objet de type valeur. Si ce constructeur n’est pas explicitement implémenté, il est rajouté par le compilateur.

L’initialisation à zéro consiste à affecter:

  • 0 aux membres de type intégral, 0.0f aux float, 0.0d aux double, 0m aux decimal,
  • false aux objets bool,
  • null aux objets de type référence,
  • Initialiser à zéro les membres de type valeur.

Caractéristiques des objets struct

En plus des caractéristiques des objets de type valeur, une struct ne peut pas être statique, ne peut pas hériter d’une autre struct et ne peut pas être abstraite. Une struct peut satisfaire une interface et peut avoir des membres statiques.

Ainsi, si on considère:

internal struct StructExample
{
  public int IntegerMember;
  public string StringMember;
  public ClassExample ClassMember;
}

internal class ClassExample { }

Alors:

var structExample = new StructExample();
Console.WriteLine(structExample.IntegerMember);  // 0
Console.WriteLine(structExample.StringMember);   // null
Console.WriteLine(structExample.ClassMember);    // null

Cette implémentation ne génère pas d’erreurs toutefois des warnings indiquent que les membres ne sont jamais initialisés:

warning CS0649: Field 'StructExample.StringMember' is never assigned to, and will always have its default value null
warning CS0649: Field 'StructExample.ClassMember' is never assigned to, and will always have its default value null
warning CS0649: Field 'StructExample.IntegerMember' is never assigned to, and will always have its default value 0

Les warnings disparaissent si on initialise les membres:

var structExample = new StructExample { IntegerMember = 0, StringMember = string.Empty, ClassMember = new ClassExample() };

Concernant les autres caractéristiques des structs:

internal abstract struct StructExample {}  // ⚠ Erreur de compilation ⚠

internal static struct StructExample {}  // ⚠ Erreur de compilation ⚠

internal struct OtherStruct {} 
internal struct StructExample: OtherStruct {}  // ⚠ Erreur de compilation ⚠

Mais:

internal interface IExample {} 
internal struct StructExample: IExample {}  // OK

Les structures ne peuvent pas contenir de destructeurs:

internal struct StructExample
{
  ~StructExample() {} // ⚠ Erreur de compilation ⚠
}

Code MSIL

Du coté du code MSIL, les différences ne sont pas très grandes entre les classes et les structs. Si on considère les 2 objets suivants dont les implémentations sont volontairement très proches:

internal struct StructExample
{
  public int IntegerMember;

  public StructExample(int integerMember)
  {
    this.IntegerMember = integerMember;
  }
}

Et:

internal class ClassExample
{
  public int IntegerMember;

  public ClassExample(int integerMember)
  {
    this.IntegerMember = integerMember;  
  }
}

Si on regarde le code MSIL correspondant à ces 2 objets:

.class private sequential ansi sealed beforefieldinit CS10_Tests.StructExample
    extends [System.Runtime]System.ValueType
{
  .field public int32 IntegerMember

  .method public hidebysig specialname rtspecialname 
      instance void  .ctor(int32 integerMember) cil managed
  {
    // Code size     8 (0x8)
    .maxstack  8
    IL_0000:  ldarg.0
    IL_0001:  ldarg.1
    IL_0002:  stfld    int32 CS10_Tests.StructExample::IntegerMember
    IL_0007:  ret
  } 
} 

Et:

.class private auto ansi beforefieldinit CS10_Tests.ClassExample
    extends [System.Runtime]System.Object
{
  .field public int32 IntegerMember 

  .method public hidebysig specialname rtspecialname 
      instance void  .ctor(int32 integerMember) cil managed
  {
    // Code size     14 (0xe)
    .maxstack  8
    IL_0000:  ldarg.0
    IL_0001:  call     instance void [System.Runtime]System.Object::.ctor()
    IL_0006:  ldarg.0
    IL_0007:  ldarg.1
    IL_0008:  stfld    int32 CS10_Tests.ClassExample::IntegerMember
    IL_000d:  ret
  } 
} 

On remarque que le type sous-jacent des objets est le même: .class. La plus grande différence réside dans l’héritage à:

  • System.ValueType dans le cas de la struct:
    .class private sequential ansi sealed beforefieldinit CS10_Tests.StructExample
      extends [System.Runtime]System.ValueType
    
  • System.Object dans le cas de la classe:
    .class private auto ansi beforefieldinit CS10_Tests.ClassExample
      extends [System.Runtime]System.Object
    

Ensuite:

  • sealed dans le cas de la struct qui interdit l’héritage.
  • auto dans le cas de la classe qui permet au compilateur de réordonner les membres de l’objet pour réduire les “espaces morts” entre membres occupant un espace différent en mémoire.
  • sequential dans le cas de la struct pour indiquer que les membres de l’objet sont disposés séquentiellement dans l’ordre de définition.

Dans le constructeur, un appel est effectué dans le cas de la classe au constructeur de System.Object:

IL_0001:  call     instance void [System.Runtime]System.Object::.ctor()

Si on utilise ces 2 objets de cette façon:

var structExample = new StructExample(10);
Console.WriteLine(structExample.IntegerMember);

var classExample = new ClassExample(10);
Console.WriteLine(classExample.IntegerMember);

Le code MSIL correspondant est:

// Code size     35 (0x23)
.maxstack  8
IL_0000:  ldc.i4.s   10
IL_0002:  newobj   instance void CS10_Tests.StructExample::.ctor(int32)
IL_0007:  ldfld    int32 CS10_Tests.StructExample::IntegerMember
IL_000c:  call     void [System.Console]System.Console::WriteLine(int32)
IL_0011:  ldc.i4.s   10
IL_0013:  newobj   instance void CS10_Tests.ClassExample::.ctor(int32)
IL_0018:  ldfld    int32 CS10_Tests.ClassExample::IntegerMember
IL_001d:  call     void [System.Console]System.Console::WriteLine(int32)
IL_0022:  ret

On peut remarquer que pour l’instanciation de la struct ou de la classe, le même opérateur newobj est utilisé. Newobj est, en effet, utilisé dans le cas d’un objet de type référence ou d’un objet de type valeur.
On peut donc constater que le code MSIL généré est très semblable entre une classe et une struct.

Si on modifie l’implémentation de cette façon:

StructExample structExample;  // Une struct n'est pas null donc cette construction est possible
structExample.IntegerMember = 10;
Console.WriteLine(structExample.IntegerMember);

ClassExample classExample = new ClassExample(10);
Console.WriteLine(classExample.IntegerMember);

Le code MSIL est sensiblement différent et reflète la construction de la struct spécifique aux objets de type valeur. Cette construction n’est pas possible dans le cas de la classe:

// Code size     38 (0x26)
.maxstack  2
.locals init (valuetype CS10_Tests.StructExample V_0)
IL_0000:  ldloca.s   V_0
IL_0002:  ldc.i4.s   10
IL_0004:  stfld    int32 CS10_Tests.StructExample::IntegerMember
IL_0009:  ldloc.0
IL_000a:  ldfld    int32 CS10_Tests.StructExample::IntegerMember
IL_000f:  call     void [System.Console]System.Console::WriteLine(int32)
IL_0014:  ldc.i4.s   10
IL_0016:  newobj   instance void CS10_Tests.ClassExample::.ctor(int32)
IL_001b:  ldfld    int32 CS10_Tests.ClassExample::IntegerMember
IL_0020:  call     void [System.Console]System.Console::WriteLine(int32)
IL_0025:  ret

Dans le cas de la struct, newobj n’est pas utilisé mais une variable locale est déclarée et directement initialisée:

.locals init (valuetype CS10_Tests.StructExample V_0)

Comme l’objet est de type valeur, il n’est pas nul. La variable locale correspondant à l’objet est sur la pile avec:

IL_0000:  ldloca.s   V_0

Dans le cas de la classe, le constructeur est appelé et l’opérateur newobj renvoie la référence vers la pile.

Avant C# 10.0

De façon à ce que les membres d’une structure soient initialisés à zéro à l’initialisation, certaines restrictions étaient appliquées aux structures. En cas d’absence de constructeur, le compilateur ne faisait que rajouter un constructeur permettant d’initialiser à zéro les membres de la structure. Toutes les autres formes d’implémentation du constructeur où tous les membres ne sont pas initialisés, menaient à une erreur de compilation:

  • Il n’était pas possible d’implémenter un constructeur sans paramètre:
    internal struct StructExample
    {
      public int IntegerMember;
      public string StringMember;
      public ClassExample ClassMember;
    
      public StructExample() {}  // ⚠ Erreur avant C# 10.0 ⚠
    }
    
  • Les initialisations de membres au même niveau que leur déclaration n’étaient pas possible:
    internal struct StructExample
    {
      public int IntegerMember = 0;  // ⚠ Erreur avant C# 10.0 ⚠
      public string StringMember = string.Empty;
      public ClassExample ClassMember = null;
    }
    
  • Le constructeur doit initialiser toutes les données membres:
    internal struct StructExample
    {
      public int IntegerMember;
      public string StringMember;
      public ClassExample ClassMember;
    
      // Il n'est pas nécessaire que le constructeur 
      // contienne des paramètres pour tous les membres
      public StructExample(int integerMember)
      {
        IntegerMember = integerMember;
        // StringMember = string.Empty;       // ⚠ Erreur, membre non initialisé ⚠
        // ClassMember = new ClassExample();  // ⚠ Erreur, membre non initialisé ⚠
      }
    }
    

readonly struct

C# 7.2

Historiquement le mot-clé readonly pouvait être utilisé pour indiquer qu’un membre d’une classe ou d’une structure ne peut être initialisé que par un initializer (avant l’exécution du constructeur) ou par le constructeur.

A partir de C# 7.2, le mot-clé readonly peut être placé devant struct de façon à indiquer au compilateur que la structure doit être immutable. Par suite le compilateur vérifiera que les membres de la structure ne peuvent pas être modifiés:

  • Une propriété ne pourra pas avoir d’accesseurs en écriture:
    public readonly struct MyStruct  
    {  
      public int WritableProp { get; set; } // ERREUR  
    
      public int ReadOnlyProp { get; } // OK  
    }
    
  • Les variables membres publiques doivent utiliser le mot-clé readonly:
    public readonly struct MyStruct  
    {  
      public int WritableMember; // ERREUR  
    
      public readonly int ReadOnlyMember; // OK  
    }
    
  • La déclaration d’évènements dans la structure n’est pas autorisée:
    public readonly struct MyStruct  
    {  
      public event EventHandler Event; // ERREUR  
    }
    

Ainsi la syntaxe permet de garantir que la structure est immutable.

Pour davantage de détails, voir cdiese.fr/csharp7-ref-struct/.

ref struct

C# 7.2

ref peut être utilisé quand on déclare un objet struct pour indiquer qu’une instance de la structure ne peut se trouver que dans la pile et ne pourra pas correspondre à une allocation dans le tas managé, par exemple:

ref struct StackOnlyStruct  
{ ... }  

readonly ref struct

C# 7.2

On peut utiliser à la fois readonly et ref devant struct pour cumuler les caractéristiques de ref struct et readonly struct:

  • ref struct pour contraindre une structure à être stockée exclusivement sur la pile et
  • readonly struct pour rendre une structure immutable.

Quand on déclare la structure, le mot-clé readonly doit se trouver obligatoirement avant ref:

readonly ref struct ImmutableStackOnlyStruct  
{ ... }  

ref struct et readonly ref struct disposable

C# 8.0

A partir de C# 8.0, les structures de type ref struct ou readonly ref struct peuvent être disposable. Sachant que les ref struct et les readonly ref struct sont stockées seulement sur la pile, il n’est pas possible de les faire satisfaire une interface, on ne peut donc pas implémenter IDisposable. A partir de C# 8.0, si une ref struct ou une readonly ref struct implémente une méthode publique void Dispose() alors la structure sera disposable sans avoir à rajouter explicitement : IDisposable.

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

ref struct DisposableStruct
{
  public Dispose()
  {
    Console.WriteLine("Disposed");
  }
}

En exécutant le code suivant, la méthode Dispose() est bien exécutée:

using (new DisposableStruct())
{
}

Amélioration des structs en C# 10 et C# 11

De façon à rendre les structs moins contraignantes à utiliser et de les rapprocher des fonctionnalités de classes, quelques améliorations ont été apportées:

  • A partir de C# 10:
    • Il est désormais possible de déclarer un constructeur sans paramètre.
    • L'initialisation d'un membre ou d'une propriété est possible directement au niveau de se déclaration.
    • On peut utiliser l'opérateur with avec des structs.
  • A partir de C# 11, il n'est plus nécessaire que le constructeur initialise tous les membres.

Constructeur sans paramètre

C# 10

A partir de C# 10, il n'est plus obligatoire d'utiliser un constructeur avec au moins un paramètre. On peut désormais implémenter un constructeur sans paramètre toutefois il est obligatoire d'initialiser tous les membres en C# 10 (cette obligation n'est plus valable avec C# 11). Si les membres ne sont pas initialisés explicitement, des erreurs de compilation sont générées:

internal struct StructExample
{
  public int IntegerMember;
  public string StringMember;
  public ClassExample ClassMember;

  public StructExample()
  {
    // ⚠ ERREUR en C# 10 ⚠
  }
}

Erreurs de compilation si les membres ne sont pas explicitement initialisés:

error CS0171: Field 'StructExample.IntegerMember' must be fully assigned before control is returned to the caller. 
error CS0171: Field 'StructExample.StringMember' must be fully assigned before control is returned to the caller. 
error CS0171: Field 'StructExample.ClassMember' must be fully assigned before control is returned to the caller. 

Si on initialise tous les membres:

internal struct StructExample
{
  public int IntegerMember;
  public string StringMember;
  public ClassExample ClassMember;

  public StructExample()
  {
     IntegerMember = 0;
     StringMember = string.Empty;
     ClassMember = new ClassExample();    
  }
}

L'obligation d'initialiser explicitement les membres disparait avec C# 11.

Initialisation des membres ou propriétés directement lors de leur déclaration

C# 10

Désormais il est possible d'initialiser des membres et des propriétés d'une struct lors de leur déclaration. Lorsqu'au moins un membre est initialisé lors de sa déclaration, un constructeur explicite est requis sinon une erreur de compilation est générée (cette obligation ne s'applique pas pour une propriété):

internal struct StructExample
{
   public int IntegerMember = 0; // ⚠ ERREUR ⚠: au moins un constructeur est requis
}

Cette implémentation entraîne une erreur à la compilation:

error CS8983: A 'struct' with field initializers must include an explicitly declared constructor.

L'implémentation d'un constructeur sans paramètre suffit:

internal struct StructExample
{
  // Membres
  private ClassExample classExample = new ClassExample();

  public int IntegerMember = 0;
  public string StringMember = string.Empty;


  // Constructeur sans paramètre
  public StructExample() {}

  // Propriété
   public ClassExample ClassMember => this.classExample;
}

Utilisation de with avec des structs

C# 10

Si on considère la struct:

internal struct StructExample
{
  public int IntegerMember;
  public string StringMember;
}

On peut utiliser l'opérateur with (introduit en C# 9) pour construire une autre instance d'une struct en se basant sur la 1ère instance:

var firstExample = new StructExample { IntegerMember = 10, StringMember = "First" };
var secondExample = firstExample with { StringMember = "Second" };

Console.WriteLine(secondExample.IntegerMember);  // 10
Console.WriteLine(secondExample.StringMember);   // Second

L'objet généré par l'opérateur with (cf. secondExample) possède des membres avec les mêmes valeurs que l'objet à gauche de l'opérateur (cf. firstExample) à l'exception des membres dont on modifie explicitement la valeur (comme StringMember):

10 
Second

L'initialisation explicite des membres n'est plus obligatoire

C# 11

A partir de C# 11, il n'est plus nécessaire d'initialiser tous les membres dans le constructeur. Dans le cas où les membres ne sont pas initialisés explicitement, ils sont initialisés à zéro (comme dans le cas où il n'y a pas de constructeur):

internal struct StructExample
{
  public int IntegerMember;
  public string StringMember;
  public ClassExample ClassMember;

  public StructExample() { }
}

Cette implémentation ne générera pas d'erreurs à la compilation toutefois des warnings seront générés car les membres ne sont pas initialisés:

warning CS0649: Field 'StructExample.StringMember' is never assigned to, and will always have its default value null
warning CS0649: Field 'StructExample.ClassMember' is never assigned to, and will always have its default value null
warning CS0649: Field 'StructExample.IntegerMember' is never assigned to, and will always have its default value 0 

Les membres sont initialisés à zéro (comme dans le cas d'une absence de constructeur):

var structExample = new StructExample();
Console.WriteLine(structExample.IntegerMember);   // 0 
Console.WriteLine(structExample.StringMember);    // null
Console.WriteLine(structExample.ClassMember);     // null

En initialisant les membres, les warnings disparaissent:

internal struct StructExample
{
  public int IntegerMember;
  public string StringMember;
  public ClassExample ClassMember;

  public StructExample()
  {
    this.ClassMember = new ClassExample();
    this.StringMember = string.Empty;
    this.IntegerMember = 0;
  }
}

Leave a Reply