Pattern matching (C# 7)

Basique

Cet article fait partie d’une série d’articles sur les apports fonctionnels de C# 7 (i.e. C# 7.0/7.1/7.2/7.3).

A partir de C# 7.0, quelques notions de programmation fonctionnelle sont introduites. Dans la documentation, même si l’expression “pattern matching” (i.e. “filtre avec motif”) est utilisée pour qualifier ces notions, il ne s’agit que de quelques améliorations pour simplifier certains éléments de syntaxe. Il n’y a pas, à proprement parlé, d’introduction de nouveaux concepts, il s’agit juste de raccourcis pour simplifier la syntaxe.

Ces notions de pattern matching peuvent être mises en place avec les mot-clés is et switch...case, elles permettent d’implémenter un filtre dans lequel on pourra indiquer des conditions. Suivant si une condition est vraie, une action spécifique pourra être exécutée.

Pour la suite, on considère les classes suivantes:

abstract class Vehicle  
{  
    public abstract int GetWheelCount();  
}  


class MotoBike : Vehicle  
{  
    public int Power => 100;  
    
    public override int GetWheelCount()  
    {  
        return 2;  
    }
}  

class Car : Vehicle  
{  
    public int PassengerCount => 4;  
    
    public override int GetWheelCount()  
    {  
        return 4;  
    }  
}  

Avec is

C# 7.0

L’opérateur is permet de tester une expression pour savoir si elle satisfait une condition particulière. Chaque type de condition correspond à un motif (i.e. pattern). En C# 7.0, les motifs possibles sont:

  • Null pattern: test par rapport à une valeur nulle, par exemple:
    Vehicle vehicle = new Car();  
    if (vehicle is null)  
        Console.WriteLine($"{nameof(vehicle)} is null.");  
    else  
        Console.WriteLine($"{nameof(vehicle)} is not null.");  
    

    Cette fonctionnalité est disponible à partir de C# 7.0.

  • Constant pattern: test en comparant par rapport à une constante, par exemple:
    object carAsObj = new Car();  
    if (carAsObj is "45")  
        Console.WriteLine($"{nameof(carAsObj)} is 45.");  
    else  
        Console.WriteLine($"{nameof(carAsObj)} is not 45.");  
    

    Cette fonctionnalité est disponible à partir de C# 7.0.

  • Type pattern: l’expression est testée suivant un type particulier (possible avant C# 7.0):
    Vehicle vehicle = new Car();  
    if (vehicle is Car)  
        Console.WriteLine($"{nameof(vehicle)} is a car.");  
    else if (vehicle is Motobike)  
        Console.WriteLine($"{nameof(vehicle)} is a motobike.");  
    else  
        Console.WriteLine($"{nameof(vehicle)} has not been identified.");  
    

Dans le cas où on utilise is pour tester le type d’une expression, il est possible de combiner is et as en une seule ligne pour simplifier la syntaxe. Pour remplacer les 2 lignes:

<expression à tester> is <type voulu>  
var <variable typée> = <variable> as <type voulu>  

On peut simplifier la syntaxe en écrivant:

<expression à tester> is <type voulu> <nom variable>  

Par exemple, si on considère le code suivant:

Vehicle vehicle = new Car();  
if (vehicle is Car)  
{  
    var car = vehicle as Car;
    Console.WriteLine($"{nameof(vehicle)} is a car with {car.PassangerCount} passagers.");  
}  
else if (vehicle is Motobike)  
{  
    var motobike = vehicle as Motobike;
    Console.WriteLine($"{nameof(vehicle)} is a motobike of {motobike.Power} horsepower.");  
}  
else  
    Console.WriteLine($"{nameof(vehicle)} has not been identified.");  

La syntaxe peut être simplifier:

if (vehicle is Car car)  
    Console.WriteLine($"{nameof(vehicle)} is a car with {car.PassangerCount} passagers.");  
else if (vehicle is Motobike motobike)  
    Console.WriteLine($"{nameof(vehicle)} is a motobike of {motobike.Power} horsepower.");  
else  
    Console.WriteLine($"{nameof(vehicle)} has not been identified.");  

Avec switch…case

C# 7.0

L’intérêt du pattern matching est de pouvoir simplifier la syntaxe en utilisant les apports de is dans une clause switch...case. Le type de condition applicable à is sont les mêmes pour switch...case. En une seule ligne, on peut tester les motifs suivant:

  • Si une expression est nulle (i.e. null pattern),
  • Si une expression est constante (i.e. constant pattern) et
  • Si une expression correspond à un type particulier (i.e. type pattern).

Si on prend l’exemple précédent, le code équivalent en utilisant switch...case pourrait être:

Vehicle vehicle = new Car();  
switch (vehicle)  
{  
    case Car car: // Type pattern
        Console.WriteLine($"{nameof(vehicle)} is a car with {car.PassengerCount} passagers.");  
        break;  
    case Motobike motobike:  // Type pattern
        Console.WriteLine($"{nameof(vehicle)} is a motobike of {motobike.Power} horsepower.");  
        break;  
    default:  
        Console.WriteLine($"{nameof(vehicle)} has not been identified.");  
        break;  
}  

D’autres conditions peuvent être utilisées notamment en testant la nullité ou l’égalité à une constante:

object carAsObj = new Car();  
switch (casAsObj)  
{  
    case null:  // Null pattern
        Console.WriteLine("Is null");  
        break;  
    case "45":  // Constant pattern 
        Console.WriteLine("Is a constant, not a vehicle.");  
        break;  
    case Car car:  // Type pattern
        Console.WriteLine($"{nameof(carAsObj)} is a car with {car.PassengerCount} passagers.");  
        break;  
    case Motobike motobike:  // Type pattern
        Console.WriteLine($"{nameof(carAsObj)} is a motobike of {motobike.Power} horsepower.");  
        break;  
    default:  
        Console.WriteLine($"{nameof(carAsObj)} has not been identified.");  
        break;  
}  

when avec switch…case

C# 7.0

Au-delà des tests sur la nullité, un type ou l’égalité par rapport à une constante, il est possible de tester d’autres conditions en utilisant le mot-clé when.

Par exemple, si on souhaite ajouter des conditions quand un objet est de type Car:

Vehicle vehicle = new Car();  
switch (vehicle)  
{  
    case Car car when car.PassengerCount < 1:  
        Console.WriteLine($"{nameof(vehicle)} is an empty car.");  
        break;  
    case Car car when car.PassengerCount > 3 && car.PassengerCount <= 5:  
        Console.WriteLine($"{nameof(vehicle)} is a fully loaded car.");  
        break;  
    case Car car when car.PassengerCount > 8:  
        Console.WriteLine($"{nameof(vehicle)} is a heavy loaded car.");  
        break;  
    default:  
        Console.WriteLine($"{nameof(vehicle)} has not been identified.");  
        break;  
}  

Dans le cas où l’ordre des conditions ne permet pas à certains cas d’être atteint, une erreur est émise à la compilation.

Par exemple:

Vehicle vehicle = new Car();  
switch (vehicle)  
{  
    case Car car: // Provoque une erreur de compilation  
        Console.WriteLine($"{nameof(vehicle)} is a car with {car.PassengerCount} passagers.");  
        break;  
    case Car car when car.PassengerCount < 1:  
        Console.WriteLine($"{nameof(vehicle)} is an empty car.");  
        break;  
    default:  
        Console.WriteLine($"{nameof(vehicle)} has not been identified.");  
        break;  
}  

Dans ce cas, la condition when car.PassengerCount < 1 n’est jamais atteinte car elle est occultée par la ligne case Car car.

Toutefois dans certains cas, du code peut ne jamais être atteint et aucune erreur de compilation ne sera générée, par exemple:

switch (vehicle)  
{  
    case Car car when car.PassengerCount > 1:  
        Console.WriteLine($"{nameof(vehicle)} with {car.PassengerCount} passenger(s).");  
        break;  
    case Car car when car.PassengerCount > 3: // ce code ne sera jamais atteinte  
        Console.WriteLine($"{nameof(vehicle)} is full.");  
        break;  
    default:  
        Console.WriteLine($"{nameof(vehicle)} has not been identified.");  
        break;  
}  

La condition when car.PassengerCount > 1 s’applique avant when car.PassengerCount > 3 donc cette partie du code ne sera jamais atteinte.

Utilisation de var avec is ou switch…case

C# 7.0

Le 4e motif de filtre utilisable avec C# 7.0 avec is et switch...case est var (i.e. var pattern). La syntaxe générale avec is est:

<expression> is var <nom de la variable> 

Cette syntaxe correspond, d’une part à une condition appliquée à <expression> et d’autre part, elle permet de créer une variable contenant le résultat d’une expression.

La condition est toujours vraie même si le résultat de l’expression testée est nulle. Le type de la variable correspond au type de l’expression et si l’expression est nulle alors la variable sera nulle.

L’intérêt de cette construction est de créer une variable temporaire qui pourra servir pour d’autres traitements, par exemple:

List<Vehicle> vehicles = new List<Vehicle>{ new Car() }; 
if (vehicles.FirstOrDefault(v => v.GetWheelCount() > 3) is var bigVehicle) 
{ 
    if (bigVehicle.GetWheelCount() == 4) 
        Console.WriteLine("The vehicle is a car"); 
    else if (bigVehicle.GetWheelCount() == 6) 
        Console.WriteLine("The vehicle is a little truck"); 
    else if (bigVehicle.GetWheelCount() > 6) 
        Console.WriteLine("The vehicle is a big truck"); 
} 

Dans cet exemple, la ligne vehicles.FirstOrDefault(...) is var bigVehicle permet de créer la variable bigVehicle qui pourra être utilisée dans la clause if.

De la même façon que pour les autres types de motifs, le motif var peut être utilisé avec switch...case, par exemple:

switch(vehicles.FirstOrDefault(v => v.GetWheelCount() > 3) 
{ 
    case null: 
        Console.WriteLine("No big vehicle round"); 
        break; 
    case var car when car.GetWheelCount() == 4: 
        Console.WriteLine("The vehicle is a little truck"); 
        break; 
    case var truck when truck.GetWheelCount() == 6: 
        Console.WriteLine("The vehicle is a little truck"); 
        break; 
    case var bigTruck when bigTruck.GetWheelCount() > 6: 
        Console.WriteLine("The vehicle is a big truck"); 
        break; 
} 

Objets de type valeur

Certaines implémentations peuvent dégrader les performances si on utilise le pattern matching avec des objets de type valeur. Dans le cas où le code est exécuté fréquemment, il convient d’éviter ces constructions.

Par exemple, comparer un objet de type valeur avec une constante avec l’opérateur is occasionne du boxing:

int number = 6; 
if (number is 42) // Boxing 
{ ... } 

Cette implémentation occasionne 2 cas de boxing: pour la constante et pour la variable number.

Il n’y a pas de boxing si on utilise une construction similaire avec switch...case:

int number = 6; 
switch(number) 
{ 
    case 42: // Pas de boxing 
    ... 
    break 
} 

En revanche si on utilise variable de type object, il peut y avoir de l’unboxing:

object number = 6; // Boxing 
switch(number) 
{ 
    case 42: // Unboxing 
    ... 
    break 
} 

Si on utilise une variable de type int sans passer par une variable de type object, il n’y a pas d’unboxing:

int number = 5; 
// ... 
switch (number) 
{ 
    case 42: 
        Console.WriteLine("OK"); 
        break; 
    case int positiveInt when positiveInt > 0: 
        Console.WriteLine("Positive number"); 
        break; 
    case int negativeInt when negativeInt < 0: 
        Console.WriteLine("Negative number"); 
        break; 
    case int nullInt when nullInt == 0: 
        Console.WriteLine("Number is null"); 
        break; 
} 

Support des génériques

C# 7.1

Le pattern matching supporte les génériques à partir de C# 7.1.

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

abstract class Vehicle 
{ 
    public int PassengerCount  { get; set; } 
} 

class Car : Vehicle 
{ } 

class MotoBike : Vehicle 
{ } 

Des conditions du pattern matching peuvent s’appliquer sur le type générique, par exemple:

public void DisplayVehicleDetail<TVehicle>(TVehicle vehicle)  
where TVehicle: Vehicle 
{ 
    switch (vehicle) 
    { 
        case Car car: 
            Console.WriteLine($"Vehicle is a car with {car.PassengerCount} passengers."); 
            break; 
        case MotoBike moto: 
            Console.WriteLine($"Vehicle is a moto."); 
            break; 
        default: 
            Console.WriteLine($"Vehicle has not been identified."); 
            break;
    } 
} 

Point de vue d’architecture

Quand on utilise la programmation orientée objet comme on le fait en C#, une pratique courante est de tenter de généraliser des traitements pour en déduire une abstraction pour utiliser cette abstraction dans une classe parente. Les traitements plus spécifiques pourront être implémentés dans des classes enfants qui dérivent de la classe parente.

L’intérêt de cette abstraction est d’identifier les comportements similaires, d’en déduire une implémentation générale dans le but d’éviter des duplications des comportements et du code. Quand on instancie un objet enfant et qu’on exécute un traitement, l’implémentation de la classe va ainsi effectuer une partie de traitement en s’appuyant sur l’implémentation générique de la classe parente, et une autre partie sur l’implémentation spécifique à la classe enfant suivant les règles d’héritage et d’encapsulation. Du point de vue externe à la classe, on peut considérer:

  • Le type correspondant à la classe enfant et accéder aux fonctions spécifiques à cette classe enfant.
  • Le type de la classe parente et ainsi accéder seulement aux fonctions génériques non spécialisées.

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

abstract class Vehicle 
{ 
    public void MoveForward() { … } 

    public void MoveReverse() { … } 

    public abstract void AddPassenger(); 
} 

class Car : Vehicle 
{ 
    public override void AddPassenger() { ... }  

    public void PutInTrunk() { ... }  
} 

class MotoBike : Vehicle 
{ 
    public override void AddPassenger() { ... }  

    public bool IsHelmetAvailable() { ... }  
} 

Dans cet exemple, le type Vehicle permet d’appeler de l’extérieur:

  • Des fonctions génériques comme MoveForward() ou MoveReverse().
  • Des fonctions dont l’implémentation est spécifique comme AddPassenger().

Vu de l’extérieur à l’objet, suivant le type instancié, on ne peut considérer que le type parent ou le type enfant. Il devient plus difficile de considérer les 2 types à la fois:

  • Effectuer un traitement sur le type de la classe parente et
  • Appliquer des comportements spécifiques suivant le type précis de l’objet.

Ainsi, dans le cas de l’exemple:

  • Soit on considère le type Vehicle:
    Vehicle vehicle = new Car();
    

    Exposer le type Vehicle permet d’éviter d’exposer la complexité de l’implémentation toutefois on ne peut pas accéder aux fonctions spécifiques au type Car.

  • Soit on considère directement le type précis de la classe:
    Car car = new Car(); 
    

    On perd l’intérêt d’avoir créé un type générique car on expose un type trop précis.

Les solutions à ce problème pourraient être:

  • D’effectuer des “casts” pour avoir les types précis et accéder aux fonctions spécifiques:
    Car car = vehicle as Car;  
    car.PutInTrunk(); 
    

    ou

    MotoBike moto = vehicle as MotoBike; 
    moto.IsHelmetAvailable(); 
    
  • Une autre possibilité est d’utiliser le pattern Visiteur:

Le pattern matching offre une 3e solution dont l’avantage est de présenter le code de façon plus synthétique:

switch (vehicle) 
{ 
    case Car car: 
        car.AddPassenger(); 
        car.MoveForward(); 
        break; 
    case MotoBike moto when moto.IsHelmetAvailable(): 
        moto.AddPassenger(); 
        car.MoveForward(); 
        break; 
    default: 
        vehicle.MoveReverse(); 
        break; 

} 
Share on FacebookShare on Google+Tweet about this on TwitterShare on LinkedInEmail this to someonePrint this page

Leave a Reply