Cet article fait partie d’une série d’articles sur les apports fonctionnels de C# 9.0.
Covariance pour le retour de fonction
Conséquences de la covariance dans le code MSIL
newslot
PreserveBaseOverridesAttribute
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>
versFunc<Vehicle>
. Cette conversion est possible grâce à la signatureFunc<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 deobject
doncobject
est plus général questring
. Le mot-cléout
dans la déclarationIVehicle<out T>
indique que le typeT
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>
versAction<Car>
. Cette conversion est possible grâce à la signatureAction<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 deobject
doncobject
est plus général questring
. Le mot-cléin
dans la déclarationIVehicle<in T>
indique que le typeT
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
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 etoverride
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 deVehicle
. - 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.
- Covariant returns: https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/csharp-9.0/covariant-returns
- out (generic modifier): https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/out-generic-modifier
- Variance in Delegates: https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/concepts/covariance-contravariance/variance-in-delegates
- Covariance and Contravariance: https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/concepts/covariance-contravariance/
- Virtual method table: https://en.wikipedia.org/wiki/Virtual_method_table
- MSIL in Depth: http://etutorials.org/Programming/programming+microsoft+visual+c+sharp+2005/Part+IV+Debugging/Chapter+11+MSIL+Programming/MSIL+in+Depth/
- PreserveBaseOverridesAttribute Classe: https://docs.microsoft.com/fr-fr/dotnet/api/system.runtime.compilerservices.preservebaseoverridesattribute?view=net-6.0