Covariance pour le retour de fonction (C# 9.0)

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

Avant de rentrer dans le détail de la fonctionnalité “covariant return”, on va expliquer ce que signifie le terme “covariant” (i.e. covariance). Dans un 2e temps, on expliquera quelques subtilités de la fonctionnalité en vérifiant les conséquences du point de vue du code MSIL.

Variance

La variance consiste à donner la possibilité de considérer les signatures des fonctions de façon moins stricte suivant les critères de dérivation des types des arguments. Ainsi des déclarations d’affectation d’un argument de fonction et de retours de fonction peuvent être considérées syntaxiquement correcte alors que le type des objets dans la signature de la fonction n’est pas rigoureusement respectés. On considère 2 types de variance:

  • Covariance qui permet d’assigner un delegate qui retourne un objet dont le type est moins précis dans l’arbre de dérivation par rapport au type de la signature originale, par exemple:

    Si considère les objets suivants:

    public class Vehicle {} 
    public class Car : Vehicle {}
    

    Alors on peut écrire:

    Func<Car> getNewCar = () => new Car();
    Func<Vehicle> getNewVehicle = getNewCar;
    

    Implicitement, il y a une conversion de type de Func<Car> vers Func<Vehicle>. Cette conversion est possible grâce à la signature Func<T> qui autorise ce type de conversion à cause du mot-clé out:

    public delegate TResult Func<out TResult>();
    

    Ce type de conversion est aussi possible avec les interfaces:

    public interface IVehicle<out TId> 
    {
      TId Id { get; }
    }
    
    public class Car<TId>: IVehicle<TId>
    {
        public TId Id { get; }
    }
    
    //...
    IVehicle<string> carWithStringId = new Car<string>();
    IVehicle<object> carWithObjectId = carWithStringId;
    

    Cette conversion implicite n’est possible que pour les delegates et les interfaces:

    // ⚠ ERREUR ⚠ Only interface and delegate type parameters can be specified as variant.
    public class Vehicle<out T>  
    {
      ...
    }
    

    D’autre part, le type string dérive de object donc object est plus général que string. Le mot-clé out dans la déclaration IVehicle<out T> indique que le type T est destiné à être retourné et non à être utilisé comme argument. Comme les affectations suivantes sont compatibles alors la covariance est possible:

    string varAsString = "example";
    object varAsObject = varAsString; // Conversion implicite
    
  • Contravariance consistant à accepter des types moins précis dans l’arbre de dérivation concernant le type des arguments d’un delegate.
    La contravariance est utilisée dans le cadre du type des arguments indiqués dans un generic d’un delegate ou d’une interface:

    Si considère les objets suivants:

    public class Vehicle {} 
    public class Car : Vehicle {}
    

    Alors on peut écrire:

    Action<Vehicle> useVehicle = v => Console.WriteLine(v.GetType());
    Action<Car> useCar = useVehicle;
    

    Implicitement, il y a une conversion de type de Action<Vehicle> vers Action<Car>. Cette conversion est possible grâce à la signature Action<T> qui autorise ce type de conversion à cause du mot-clé in:

    public delegate void Action<in T>(T object);
    

    Ce type de conversion est aussi possible avec les interfaces:

    public interface IVehicle<in TId> 
    {
      void SetVehicleId(TId vehicleId);
    }
    
    public class Car<TId>: IVehicle<TId>
    {
      public void SetVehicleId(TId vehicleId)
      {
        // ... 
      }
    }
    
    //...
    IVehicle<object> vehicleWithObjectId = new Car<object>();
    IVehicle<string> vehicleWithStringId = vehicleWithObjectId;
    

    Cette conversion implicite n’est possible que pour les delegates et les interfaces:

    // ⚠ ERREUR ⚠: Only interface and delegate type parameters can be specified as variant.
    public class Car<in T>
    {
      ...
    }
    

    Comme précédemment, le type string dérive de object donc object est plus général que string. Le mot-clé in dans la déclaration IVehicle<in T> indique que le type T est destiné à être utilisé comme argument. Si on considère une méthode dont la signature est:

    void SetVehicleId(object id) {}
    

    On peut écrire:

    string id = "id";
    SetVehicleId(id); // Conversion implicite du type de l'argument
    

Covariance pour le retour de fonction

C# 9.0

Dans le cadre de C# 9.0, la covariance est étendue à la surcharge des fonctions virtuelles en permettant de retourner un type plus précis dans l’arbre de dérivation que le type original de la signature. Par exemple, si on considère les objets suivants:

public class Vehicle {}
public class Car: Vehicle {}

public class VehicleFactory
{
  public virtual Vehicle CreateNewVehicle() => new Vehicle();
}

public class CarFactory : VehicleFactory
{
  public override Car CreateNewVehicle() => new Car();
}

La fonction surchargée CarFactory.CreateNewVehicle() retourne le type Car qui est plus précis que le type Vehicle de la signature originale de la fonction virtuelle VehicleFactory.CreateNewVehicle(). Cette fonctionnalité s’appelle “covariant return” en référence à la covariance plus haut.

Cette fonctionnalité est aussi valable pour les propriétés en lecture seule:

public class VehicleWrapper
{
  public VehicleWrapper()
  {
    this.Vehicle = new Vehicle();
  }

  public virtual Vehicle Vehicle { get; }
} 

public class CarWrapper : VehicleWrapper
{
  public CarWrapper()
  {
    this.Vehicle = new Car();
  }

  public override Car Vehicle { get; }
}

Si la propriété comporte un setter, la signature de la surcharge doit comporter le type original exacte:

public class VehicleWrapper
{
  // ...  
  
  public virtual Vehicle Vehicle { get; set; }
} 

public class CarWrapper : VehicleWrapper
{
  // ...

  // ⚠ ERREUR ⚠: covariant return type of property can only be used if the overriding property is read-only. 
  public override Car Vehicle { get; set; } 
}

Cette limitation s’explique car l’affectation de la propriété de l’extérieur est ambigue car on ne sait pas le type attendu: Car ou Vehicle ?
L’utilisation du getter de la propriété ne pose pas de problème d’ambiguïté:

var vehicleWrapper = new VehicleWrapper();
Vehicle vehicle = vehicleWrapper.Vehicle; // Pas de cast nécessaire

var carWrapper = new CarWrapper();
Car car = carWrapper.Vehicle; // Pas de cast nécessaire

Conséquences de la covariance dans le code MSIL

On pourrait se demander si l’utilisation de la covariance dans le retour d’une fonction un cast implicit. On considère le code suivant:

var vehicleFactory = new VehicleFactory();
Vehicle vehicle = vehicleFactory.CreateNewVehicle();

var carFactory = new CarFactory();
Car car = carFactory.CreateNewVehicle(); 

Les implémentations de VehicleFactory et CarFactory sont précisées plus haut.

Le MSIL correspondant est:

  • Pour VehicleFactory.CreateNewVehicle():
    .method public hidebysig newslot virtual 
            instance class FunctionPointerTests.Covariant.Vehicle 
            CreateNewVehicle() cil managed
    {
      .maxstack  8
      IL_0000:  newobj     instance void FunctionPointerTests.Covariant.Vehicle::.ctor()
      IL_0005:  ret
    }
    
  • Pour CarFactory.CreateNewVehicle():
    .method public hidebysig newslot virtual 
            instance class FunctionPointerTests.Covariant.Car 
            CreateNewVehicle() cil managed
    {
      .custom instance void [System.Runtime]System.Runtime.CompilerServices.PreserveBaseOverridesAttribute::.ctor() = ( 01 00 00 00 ) 
      .override FunctionPointerTests.Covariant.VehicleFactory::CreateNewVehicle
      // Code size       6 (0x6)
      .maxstack  8
      IL_0000:  newobj     instance void FunctionPointerTests.Covariant.Car::.ctor()
      IL_0005:  ret
    }
    

On peut voir que le code MSIL correspondant aux lignes plus haut ne comporte pas de cast:

IL_0000:  newobj     instance void FunctionPointerTests.Covariant.VehicleFactory::.ctor()
IL_0005:  callvirt   instance class FunctionPointerTests.Covariant.Vehicle FunctionPointerTests.Covariant.VehicleFactory::CreateNewVehicle()
IL_000a:  pop
IL_000b:  newobj     instance void FunctionPointerTests.Covariant.CarFactory::.ctor()
IL_0010:  callvirt   instance class FunctionPointerTests.Covariant.Car FunctionPointerTests.Covariant.CarFactory::CreateNewVehicle()
IL_0015:  pop
IL_0016:  ret

Le code MSIL est le reflet du code C# et il n’y a pas de cast implicite. Dans le cas de la covariance dans le retour d’une fonction, c’est directement la méthode CarFactory.CreateNewVehicle() qui est appelée.

Si on considère le même code sans utilisation de la fonctionnalité de covariance dans le retour de la fonction:

public class VehicleFactory
{
  public virtual Vehicle CreateNewVehicle() => new Vehicle();
}

public class CarFactory : VehicleFactory
{
  public override Vehicle CreateNewVehicle() => new Car();
}

// ...

var vehicleFactory = new VehicleFactory();
Vehicle vehicle = vehicleFactory.CreateNewVehicle();

var carFactory = new CarFactory();
Vehicle car = carFactory.CreateNewVehicle(); 

Seul le code MSIL de CarFactory.CreateNewVehicle() diffère:

  • Sans utilisation de la covariance:
    .method public hidebysig virtual instance class FunctionPointerTests.Covariant.Vehicle 
            CreateNewVehicle() cil managed
    {
      // Code size       6 (0x6)
      .maxstack  8
      IL_0000:  newobj     instance void FunctionPointerTests.Covariant.Car::.ctor()
      IL_0005:  ret
    }
    
  • Si on utilise la covarianvce:
    .method public hidebysig newslot virtual 
            instance class FunctionPointerTests.Covariant.Car 
            CreateNewVehicle() cil managed
    {
      .custom instance void [System.Runtime]System.Runtime.CompilerServices.PreserveBaseOverridesAttribute::.ctor() = ( 01 00 00 00 ) 
      .override FunctionPointerTests.Covariant.VehicleFactory::CreateNewVehicle
      // Code size       6 (0x6)
      .maxstack  8
      IL_0000:  newobj     instance void FunctionPointerTests.Covariant.Car::.ctor()
      IL_0005:  ret
    }
    

Les différences concernent:

  • La présence de newslot dans la signature de la fonction
  • La présence de l’attribut System.Runtime.CompilerServices.PreserveBaseOverridesAttribute

newslot

La signature de la méthode CarFactory.CreateNewVehicle() comporte newslot quand on utilise la fonctionnalité de covariance. newslot permet d’indiquer une entrée spécifique dans le tableau des fonctions virtuelles vtable.
Le tableau des fonctions virtuelles est une solution technique pour exécuter la bonne implémentation d’une fonction dans le cas de surcharge. En effet, quand une fonction est surchargée dans une classe il existe 2 versions de la fonction:

  • Une version de base de la fonction se trouvant dans la classe mère
  • Une version surchargée (cf. overriding) de la fonction se trouvant dans la classe fille

Le polymorphisme impose que si on considère une classe suivant son type le plus général à savoir celui de la classe mère, il n’est pas possible, à la compilation, de prévoir quelle implémentation concrète d’une fonction sera exécutée. L’implémentation exécutée devra être celle correspondant au type réel de la classe connu à l’exécution. Ainsi pour pointer vers la bonne implémentation et choisir cette bonne implémentation à l’exécution, une solution technique consiste à utiliser un tableau de pointeurs de fonction pour chaque type pointant vers les différentes implémentations des fonctions. A l’exécution, suivant le type réel de la classe, le runtime appelle une fonction en utilisant le bon pointeur de fonction. En C#, ce tableau s’appelle virtual method table ou vtable (cf. wikipedia.org/wiki/Virtual_method_table).

Dans le cas de la fonctionnalité covariance pour le retour d’une fonction, la présence du mot clé newslot indique que la fonction fait l’objet d’une entrée distincte dans la vtable. Cela signifie qu’il y a bien une distinction entre l’implémentation de la fonction:

  • Dans le cas de la covariance pour le retour d’une fonction: la fonction dans la classe fille est considérée comme distincte de la fonction dans la classe mère. Même si le code C# comporte les mot clés virtual pour la méthode de la classe mère et override pour la méthode de la classe fille, la présence du mot clé newslot dans le code MSIL indique qu’il s’agit de méthodes différentes qui n’ont pas de lien.
  • En l’absence de covariance: il n’y a pas d’utilisation du mot clé newslot. La fonction de la classe fille est une surcharge de la fonction de la classe mère. Il n’y a pas forcément une entrée distincte dans la vtable.

PreserveBaseOverridesAttribute

L’attribut PreserveBaseOverridesAttribute a été introduit avec la framework .NET 5. Il permet de garantir qu’un appel à la fonction utilise l’implémentation de la classe fille même si la signature utilisée n’est celle de la classe fille.

Par exemple si on utilise l’implémentation suivante:

public class VehicleFactory
{
  public virtual Vehicle CreateNewVehicle() 
  {
    Console.WriteLine("Vehicle");
    return new Vehicle();
  }
}

public class CarFactory : VehicleFactory
{
  public override Car CreateNewVehicle() 
  {
    Console.WriteLine("Car");
    return new Car();
  }
}

A l’exécution des lignes suivantes:

var carFactory = new CarFactory();
Car car1 = carFactory.CreateNewVehicle(); // Même signature que la classe fille (CarFactory)
Vehicle car2 = carFactory.CreateNewVehicle(); // Même signature que la classe mère (VehicleFactory)

On obtient:

Car
Car

Cela signifie que dans les 2 cas quelque soit la signature utilisée c’est l’implémentation de la classe file qui est exécutée.

Pour conclure…

L’héritage est un concept puissant, un de ces intérêts est de pouvoir bénéficier du polymorphisme. La conséquence est qu’en cas d’héritage on peut choisir de surcharger des méthodes ou d’utiliser l’implémentation plus générale de la classe mère. Ce mécanisme permet d’éviter la duplication de code et de rendre plus abstrait des comportements. Un des plus gros inconvénients de l’héritage est que les méthodes surchargées doivent partager la même signature que les méthodes virtuelles. Ainsi même si une classe spécialise un comportement, les méthodes surchargées qu’elle comporte devront avoir la même signature générale que les méthodes de la classe de base. Ce gros inconvénient force à devoir effectuer des casts pour pouvoir utiliser des types plus spécialisés.

Par exemple, si on considère les classes suivantes:

public class VehicleFactory
{
  public virtual Vehicle CreateNewVehicleFrom(Vehicle template) { ... } 
} 

public class CarFactory: VehicleFactory
{
  public override Vehicle CreateNewVehicleFrom(Vehicle template) { ... } 	

  public Car CreateNewCarFrom(Car template) { ... } 	
} 

VehicleFactory comporte une fonction CreateNewVehicleFrom() dont le but est de créer une nouvelle instance de Vehicle. On peut surcharger cette fonction dans CarFactory de façon à créer une nouvelle instance de Car. CarFactory.CreateNewVehicleFrom() utilise la même signature que VehicleFactory.CreateNewVehicleFrom() or:

  • Le type de retour est imposé: on peut vouloir renvoyer une instance de Car plutôt qu’une instance de Vehicle.
  • Le type et le nombre des arguments sont imposés: on peut souhaiter utiliser un type particulier ou un nombre particulier d’arguments différents de ceux de la fonction de la classe de base.

Ainsi on peut être amené à spécialiser la signature d’une méthode surchargée même si le comportement est le même que la classe de base: dans notre cas, le comportement consiste à créer un nouveau véhicule.

Une solution rapide consiste à effectuer des casts pour utiliser un objet Car à partir d’un argument de type Vehicle. D’autres solutions peuvent être d’utiliser des patterns plus complexes comme Visiteur (voir Eviter d’effectuer des “casts” avec Bridge et Visiteur).

La fonctionnalité “covariance return” permet d’apporter une nouvelle solution à ce problème même si elle ne concerne que le retour de fonction.

Leave a Reply