Subtilité de "this" avec le polymorphisme en C#
Dans mon projet RentCRL, j’ai plusieurs types d’utilisateurs : Owner
, Tenant
, etc.
Comme souvent en test, j’ai voulu faire un builder fluide pour créer ces objets proprement.
J’ai donc commencé avec un UserBuilder
, puis j’ai fait un OwnerBuilder
qui hérite de UserBuilder
, en override la méthode Create()
pour retourner un Owner
au lieu d’un User
.
public class UserBuilder
{
protected readonly Fixture _fixture = new();
protected string _email;
protected UserBuilder()
{
_email = _fixture.CreateEmail();
}
public static UserBuilder Build()
{
return new UserBuilder();
}
public virtual User Create()
{
return new User(...);
}
public UserBuilder WithEmail(string email)
{
_email = email;
return this;
}
}
public class OwnerBuilder : UserBuilder
{
public static new OwnerBuilder Build()
{
return new OwnerBuilder();
}
public override Owner Create()
{
return new Owner(...);
}
}
Je pensais que ça suffirait.
Mais en chaînant les appels :
var owner = OwnerBuilder.Build()
.WithEmail("john@example.com")
.Create();
Je me suis rendu compte que Create()
ne retournait pas un Owner
, mais un User
.
1 - Ce que j’ai compris
Même si j’appelais Build()
sur OwnerBuilder
, les méthodes WithX()
venaient de UserBuilder
, donc elles retournaient... un UserBuilder
.
Et this
dans UserBuilder
, c’est bien le type de la classe de base, pas celui de la méthode statique Build()
.
Donc toute ma chaîne retournait UserBuilder
, pas OwnerBuilder
, et au final Create()
appelait la mauvaise méthode.
2 - Ce que j’ai changé
J’ai refait la structure avec une classe générique UserBuilderGeneric<TBuilder>
, et chaque sous-classe (comme OwnerBuilder
) devait juste implémenter une méthode GetBuilder()
qui retourne this
, mais typée correctement.
Comme ça, toutes les méthodes WithX()
dans la classe de base peuvent retourner TBuilder
sans faire de cast.
2.1 - Exemple :
public abstract class UserBuilderGeneric<TBuilder>
where TBuilder : UserBuilderGeneric<TBuilder>
{
protected readonly Fixture _fixture = new();
protected string _email;
protected UserBuilderGeneric()
{
_email = _fixture.CreateEmail();
}
protected abstract TBuilder GetBuilder();
public TBuilder WithEmail(string email)
{
_email = email;
return GetBuilder();
}
}
public class UserBuilder : UserBuilderGeneric<UserBuilder>
{
protected override UserBuilder GetBuilder() => this;
public static UserBuilder Build()
{
return new UserBuilder();
}
public override User Create()
{
return new User(...);
}
}
public class OwnerBuilder : UserBuilderGeneric<OwnerBuilder>
{
protected override OwnerBuilder GetBuilder() => this;
public static OwnerBuilder Build()
{
return new OwnerBuilder();
}
public override Owner Create()
{
return new Owner(...);
}
}
3 - Résultat
Je peux maintenant faire :
var owner = OwnerBuilder.Build()
.WithEmail("john@example.com")
.Create();
Et j’ai bien un Owner
à la fin, sans avoir à dupliquer les WithX()
dans chaque builder, ni faire de cast.
4 - Conclusion
C’est tout bête, mais j’étais persuadé que faire un override
de Create()
suffisait.
En fait, non : le this
dans la classe de base ne suit pas le type de la méthode Build()
dans la sous-classe.
Donc même si Build()
retourne un OwnerBuilder
, la chaîne de méthodes repasse en UserBuilder
, et Create()
appelle celle de la classe mère.
C’est pas révolutionnaire, mais j’ai trouvé ça intéressant à creuser et ça m’a permis de rendre mes builders plus propres sans copier 10 méthodes.