À propos des nouveaux éléments dans .NET 5 et C # 9.0

Bonne après-midi.



Nous utilisons .NET depuis sa création. Nous avons des solutions écrites dans toutes les versions du framework, du tout premier au dernier .NET Core 3.1.



L'histoire de .NET, que nous suivons de près depuis tout ce temps, se déroule sous nos yeux: la version de .NET 5, dont la sortie est prévue en novembre, vient de sortir sous la forme de Release Candidate 2. On nous prévient depuis longtemps que la cinquième version fera date: elle se terminera La schizophrénie .NET, quand il y avait deux branches du framework: classique et Core. Maintenant, ils vont fusionner en extase, et il y aura un .NET continu.



Sortie RC2vous pouvez déjà commencer à l'utiliser pleinement - aucun nouveau changement n'est attendu avant la publication, il n'y aura qu'une correction des bogues trouvés. De plus: RC2 dispose déjà d'un site officiel dédié à .NET.



Et nous vous présentons un aperçu des innovations de .NET 5 et C # 9. Toutes les informations avec des exemples de code sont tirées du blog officiel des développeurs de la plate-forme .NET (ainsi que de nombreuses autres sources) et vérifiées personnellement.



Nouveaux types natifs et nouveaux



C # et .NET ont simultanément ajouté des types natifs:



  • nint et nuint pour C #
  • leur System.IntPtr et System.UIntPtr correspondants dans BCL


Le point d'ajouter ces types concerne les opérations avec des API de bas niveau. Et l'astuce est que la taille réelle de ces types est déjà déterminée lors de l'exécution et dépend du bitness du système: pour les 32 bits, leur taille sera de 4 octets, et pour les 64 bits, respectivement, de 8 octets.



Très probablement, vous ne rencontrerez pas ces types dans le vrai travail. Comme, cependant, avec un autre nouveau type: la moitié. Ce type n'existe qu'en BCL, il n'y a pas encore d'analogue pour lui en C #. Il s'agit d'un type 16 bits pour les valeurs à virgule flottante. Cela peut être utile dans les cas où une précision infernale n'est pas requise, et vous pouvez gagner de la mémoire pour stocker les valeurs, car les types float et double occupent 4 et 8 octets. La chose la plus intéressante est que pour ce type en général jusqu'à présentles opérations arithmétiques ne sont pas définies et vous ne pouvez même pas ajouter deux variables de type Half sans les convertir explicitement en float ou double. Autrement dit, le but de ce type est maintenant purement utilitaire - pour économiser de l'espace. Cependant, ils prévoient d'y ajouter l'arithmétique dans la prochaine version de .NET et C #. Dans un an.



Attributs des fonctions locales



Auparavant, ils étaient interdits, ce qui créait des inconvénients. En particulier, il était impossible de suspendre les attributs des paramètres des fonctions locales. Vous pouvez maintenant leur définir des attributs, à la fois pour la fonction elle-même et pour ses paramètres. Par exemple, comme ceci:



#nullable enable
private static void Process(string?[] lines, string mark)
{
    foreach (var line in lines)
    {
        if (IsValid(line))
        {
            // Processing logic...
        }
    }

    bool IsValid([NotNullWhen(true)] string? line)
    {
        return !string.IsNullOrEmpty(line) && line.Length >= mark.Length;
    }
}


Expressions lambda statiques



Le but de cette fonctionnalité est de s'assurer que les expressions lambda ne peuvent pas capturer de contexte et de variables locales qui existent en dehors de l'expression elle-même. En général, le fait qu'ils puissent saisir le contexte local est souvent utile dans le développement. Mais parfois, cela peut être la cause d'erreurs difficiles à détecter.



Pour éviter de telles erreurs, les expressions lambda peuvent désormais être marquées avec le mot clé static. Et dans ce cas, ils perdent l'accès à tout contexte local: des variables locales à ceci et à la base.



Voici un exemple d'utilisation assez complet:



static void SomeFunc(Func<int, int> f)
{
    Console.WriteLine(f(5));
}

static void Main(string[] args)
{
    int y1 = 10;
    const int y2 = 10;
    SomeFunc(i => i + y1);          //  15
    SomeFunc(static i => i + y1);   //  : y1    
    SomeFunc(static i => i + y2);   //  15
}


Notez que les constantes capturent très bien les lambdas statiques.



GetEnumerator comme méthode d'extension



Maintenant, la méthode GetEnumerator peut être une méthode d'extension, ce qui vous permettra d'itérer à travers le foreach même qui ne pouvait pas être itéré auparavant. Par exemple - tuples.



Voici un exemple où il devient possible d'itérer sur ValueTuple via foreach en utilisant la méthode d'extension écrite pour cela:



static class Program
{
    public static IEnumerator<T> GetEnumerator<T>(this ValueTuple<T, T, T, T, T> source)
    {
        yield return source.Item1;
        yield return source.Item2;
        yield return source.Item3;
        yield return source.Item4;
        yield return source.Item5;
    }

    static void Main(string[] args)
    {
        foreach(var item in (1,2,3,4,5))
        {
            System.Console.WriteLine(item);
        }
    }
}


Ce code imprime les nombres de 1 à 5 sur la console.



Ignorer le modèle dans les paramètres des expressions lambda et des fonctions anonymes



Micro-amélioration. Si vous n'avez pas besoin de paramètres dans une expression lambda ou dans une fonction anonyme, vous pouvez les remplacer par un trait de soulignement, ignorant ainsi:



Func<int, int, int> someFunc1 = (_, _) => {return 5;};
Func<int, int, int> someFunc2 = (int _, int _) => {return 5;};
Func<int, int, int> someFunc3 = delegate (int _, int _) {return 5;};


Instructions de niveau supérieur en C #



Il s'agit d'une structure de code C # simplifiée. Maintenant, écrire le code le plus simple semble vraiment simple:



using System;

Console.WriteLine("Hello World!");


Et tout se compilera très bien. Autrement dit, vous n'avez plus besoin de créer une méthode dans laquelle l'instruction de sortie de la console doit être placée, vous n'avez pas besoin de décrire une classe dans laquelle la méthode doit être placée, et il n'est pas nécessaire de définir l'espace de noms dans lequel la classe doit être créée.



D'ailleurs, à l'avenir, les développeurs C # envisagent de développer un sujet avec une syntaxe simplifiée et tentent de se débarrasser de l'utilisation de System; dans des cas évidents. En attendant, vous pouvez vous en débarrasser en écrivant simplement comme ceci:



System.Console.WriteLine("Hello World!");


Et ce sera vraiment un programme de travail sur une seule ligne.



Des options plus complexes peuvent être utilisées:



using System;
using System.Runtime.InteropServices;

Console.WriteLine("Hello World!");
FromWhom();
Show.Excitement("Top-level programs can be brief, and can grow as slowly or quickly in complexity as you'd like", 8);

void FromWhom()
{
    Console.WriteLine($"From {RuntimeInformation.FrameworkDescription}");
}

internal class Show
{
    internal static void Excitement(string message, int levelOf)
    {
        Console.Write(message);

        for (int i = 0; i < levelOf; i++)
        {
            Console.Write("!");
        }

        Console.WriteLine();
    }
}


En réalité, le compilateur lui-même enveloppera tout ce code dans les espaces de noms et les classes nécessaires, vous ne le saurez tout simplement pas.



Bien sûr, cette fonctionnalité a des limites. Le principal est que cela ne peut être fait que dans un seul fichier de projet. En règle générale, il est logique de le faire dans le fichier où vous avez précédemment créé le point d'entrée du programme sous la forme de la fonction Main (string [] args). En même temps, la fonction Main elle-même ne peut pas y être définie - c'est la deuxième limitation. En fait, un tel fichier lui-même avec une syntaxe simplifiée est la fonction Main, et il contient même implicitement la variable args, qui est un tableau avec des paramètres. Autrement dit, ce code compilera et affichera également la longueur du tableau:



System.Console.WriteLine(args.Length);


En général, la fonctionnalité n'est pas la plus importante, mais à des fins de démonstration et de formation, elle convient parfaitement à elle-même. Détails ici .



Correspondance de modèle dans une instruction if



Imaginez que vous deviez vérifier une variable d'objet qu'elle n'appartient pas à un certain type. Jusqu'à présent, il fallait écrire comme ceci:



if (!(vehicle is Car)) { ... }


Mais avec C # 9.0, vous pouvez écrire humainement:



if (vehicle is not Car) { ... }


Il est également devenu possible d'enregistrer de manière compacte certains contrôles:



if (context is {IsReachable: true, Length: > 1 })
{
    Console.WriteLine(context.Name);
}


Cette nouvelle notation est équivalente à la bonne vieille comme ceci:



if (context is object && context.IsReachable && context.Length > 1 )
{
    Console.WriteLine(context.Name);
}


Ou vous pouvez également écrire la même chose d'une manière relativement nouvelle (mais c'est déjà hier):



if (context?.IsReachable && context?.Length > 1 )
{
    Console.WriteLine(context.Name);
}


Dans la nouvelle syntaxe, vous pouvez également utiliser les opérateurs booléens et, ou et non, plus les parenthèses pour hiérarchiser:



if (context is {Length: > 0 and (< 10 or 25) })
{
    Console.WriteLine(context.Name);
}


Et ce ne sont que des améliorations de la correspondance de motifs dans un if normal. Ce que nous avons ajouté à la correspondance de modèle pour l'expression de commutateur - lisez la suite.



Correspondance de modèle améliorée dans l'expression de commutateur



L'expression switch (à ne pas confondre avec l'instruction switch) a été considérablement améliorée en termes de correspondance de modèle. Regardons des exemples de la documentation officielle . Des exemples sont consacrés au calcul du tarif d'un certain transport à une certaine heure. Voici le premier exemple:



public decimal CalculateToll(object vehicle) =>
    vehicle switch
{
    Car c           => 2.00m,
    Taxi t          => 3.50m,
    Bus b           => 5.00m,
    DeliveryTruck t => 10.00m,
    { }             => throw new ArgumentException("Unknown vehicle type", nameof(vehicle)),
    null            => throw new ArgumentNullException(nameof(vehicle))
};


Les deux dernières lignes de l'instruction switch sont nouvelles. Les accolades représentent tout objet qui n'est pas nul. Et vous pouvez maintenant utiliser le mot-clé correspondant pour effectuer une correspondance avec null.



Ce n'est pas tout. Notez que pour chaque correspondance avec un objet, vous devez créer une variable: c pour Car, t pour Taxi, etc. Mais ces variables ne sont pas utilisées. Dans de tels cas, vous pouvez déjà utiliser le modèle de suppression dans C # 8.0 maintenant:



public decimal CalculateToll(object vehicle) =>
    vehicle switch
{
    Car _           => 2.00m,
    Taxi _          => 3.50m,
    Bus _           => 5.00m,
    DeliveryTruck _ => 10.00m,
    // ...
};


Mais à partir de la neuvième version de C #, vous ne pouvez rien écrire du tout dans de tels cas:



public decimal CalculateToll(object vehicle) =>
    vehicle switch
{
    Car             => 2.00m,
    Taxi            => 3.50m,
    Bus             => 5.00m,
    DeliveryTruck   => 10.00m,
    // ...
};


Les améliorations apportées à l'expression de commutateur ne s'arrêtent pas là. Il est désormais plus facile d'écrire des expressions plus complexes. Par exemple, le résultat renvoyé doit souvent dépendre des valeurs de propriété de l'objet transmis. Maintenant, cela peut être écrit plus court et plus pratique qu'une combinaison de if:



public static decimal CalculateToll(object vehicle) =>
    vehicle switch
{
    Car { Passengers: 0 } => 2.00m + 0.50m,
    Car { Passengers: 1 } => 2.0m,
    Car { Passengers: 2 } => 2.0m - 0.50m,
    Car => 2.00m - 1.0m,
    // ...
};


Faites attention aux trois premières lignes du commutateur: en fait, la valeur de la propriété Passengers est vérifiée, et en cas d'égalité, le résultat correspondant est renvoyé. S'il n'y a pas de correspondance, la valeur de la variante générale sera renvoyée (la quatrième ligne à l'intérieur du commutateur). À propos, les valeurs de propriété ne sont vérifiées que si l'objet de véhicule transmis n'est pas nul et est une instance de la classe Car. Autrement dit, vous ne devriez pas avoir peur de l'exception de référence nulle lors de la vérification.



Mais ce n'est pas tout. Maintenant, dans l'expression switch, vous pouvez même écrire des expressions pour une correspondance plus pratique:



public static decimal CalculateToll(object vehicle) =>
    vehicle switch
{
    Bus b when ((double)b.Riders / (double)b.Capacity) < 0.50 => 5.00m + 2.00m,
    Bus b when ((double)b.Riders / (double)b.Capacity) > 0.90 => 5.00m - 1.00m,
    Bus => 5.00m,

    DeliveryTruck t when (t.GrossWeightClass >= 5000) => 10.00m + 5.00m,
    DeliveryTruck t when (t.GrossWeightClass >= 3000 && t.GrossWeightClass < 5000) => 10.00m,
    DeliveryTruck => 8.00m,
    // ...
};


Et ce n'est pas tout. La syntaxe d'expression de commutateur a été étendue aux expressions de commutateur imbriquées pour nous permettre de décrire encore plus facilement des conditions complexes:



public static decimal CalculateToll(object vehicle) =>
    vehicle switch
{
    Car c => c.Passengers switch
    {
        0 => 2.00m + 0.5m,
        1 => 2.0m,
        2 => 2.0m - 0.5m,
        _ => 2.00m - 1.0m
    },
    // ...
};


En conséquence, si vous collez complètement tous les exemples de code déjà donnés, vous obtenez l'image suivante avec toutes les innovations décrites à la fois:



public static decimal CalculateToll(object vehicle) =>
    vehicle switch
{
    Car c => c.Passengers switch
    {
        0 => 2.00m + 0.5m,
        1 => 2.0m,
        2 => 2.0m - 0.5m,
        _ => 2.00m - 1.0m
    },

    Taxi t => t.Fares switch
    {
        0 => 3.50m + 1.00m,
        1 => 3.50m,
        2 => 3.50m - 0.50m,
        _ => 3.50m - 1.00m
    },

    Bus b when ((double)b.Riders / (double)b.Capacity) < 0.50 => 5.00m + 2.00m,
    Bus b when ((double)b.Riders / (double)b.Capacity) > 0.90 => 5.00m - 1.00m,
    Bus => 5.00m,

    DeliveryTruck t when (t.GrossWeightClass >= 5000) => 10.00m + 5.00m,
    DeliveryTruck t when (t.GrossWeightClass >= 3000 && t.GrossWeightClass < 5000) => 10.00m,
    DeliveryTruck => 8.00m,

    null => throw new ArgumentNullException(nameof(vehicle)),
    _ => throw new ArgumentException(nameof(vehicle))
};


Mais ce n'est pas tout non plus. Voici un autre exemple: une fonction ordinaire qui utilise le mécanisme d'expression de commutation pour déterminer la charge en fonction du temps passé: heure de pointe matin / soir, périodes de jour et de nuit:



private enum TimeBand
{
    MorningRush,
    Daytime,
    EveningRush,
    Overnight
}

private static TimeBand GetTimeBand(DateTime timeOfToll) =>
    timeOfToll.Hour switch
    {
        < 6 or > 19 => TimeBand.Overnight,
        < 10 => TimeBand.MorningRush,
        < 16 => TimeBand.Daytime,
        _ => TimeBand.EveningRush,
    };


Comme vous pouvez le voir, en C # 9.0, il est également possible d'utiliser les opérateurs de comparaison <,>, <=,> =, ainsi que les opérateurs logiques et, ou et non, lors de la mise en correspondance.



Mais ce n'est pas la fin. Vous pouvez maintenant utiliser ... tuples dans l'expression de commutateur. Voici un exemple complet de code qui calcule un certain coefficient par rapport au tarif, en fonction du jour de la semaine, de l'heure de la journée et de la direction du voyage (vers / depuis la ville):



private enum TimeBand
{
    MorningRush,
    Daytime,
    EveningRush,
    Overnight
}

private static bool IsWeekDay(DateTime timeOfToll) =>
    timeOfToll.DayOfWeek switch
{
    DayOfWeek.Saturday => false,
    DayOfWeek.Sunday => false,
    _ => true
};

private static TimeBand GetTimeBand(DateTime timeOfToll) =>
    timeOfToll.Hour switch
{
    < 6 or > 19 => TimeBand.Overnight,
    < 10 => TimeBand.MorningRush,
    < 16 => TimeBand.Daytime,
    _ => TimeBand.EveningRush,
};

public static decimal PeakTimePremiumFull(DateTime timeOfToll, bool inbound) =>
    (IsWeekDay(timeOfToll), GetTimeBand(timeOfToll), inbound) switch
{
    (true, TimeBand.MorningRush, true) => 2.00m,
    (true, TimeBand.MorningRush, false) => 1.00m,
    (true, TimeBand.Daytime, true) => 1.50m,
    (true, TimeBand.Daytime, false) => 1.50m,
    (true, TimeBand.EveningRush, true) => 1.00m,
    (true, TimeBand.EveningRush, false) => 2.00m,
    (true, TimeBand.Overnight, true) => 0.75m,
    (true, TimeBand.Overnight, false) => 0.75m,
    (false, TimeBand.MorningRush, true) => 1.00m,
    (false, TimeBand.MorningRush, false) => 1.00m,
    (false, TimeBand.Daytime, true) => 1.00m,
    (false, TimeBand.Daytime, false) => 1.00m,
    (false, TimeBand.EveningRush, true) => 1.00m,
    (false, TimeBand.EveningRush, false) => 1.00m,
    (false, TimeBand.Overnight, true) => 1.00m,
    (false, TimeBand.Overnight, false) => 1.00m,
};


La méthode PeakTimePremiumFull utilise des tuples pour la correspondance, ce qui est devenu possible dans la nouvelle version de C # 9.0. Au fait, si vous regardez de près le code, deux optimisations se suggèrent:



  • les huit dernières lignes renvoient la même valeur;
  • le trafic de jour et de nuit a le même coefficient.


En conséquence, le code de méthode peut être considérablement réduit en utilisant le modèle de suppression:



public static decimal PeakTimePremiumFull(DateTime timeOfToll, bool inbound) =>
    (IsWeekDay(timeOfToll), GetTimeBand(timeOfToll), inbound) switch
{
    (true, TimeBand.MorningRush, true)  => 2.00m,
    (true, TimeBand.MorningRush, false) => 1.00m,
    (true, TimeBand.Daytime,     _)     => 1.50m,
    (true, TimeBand.EveningRush, true)  => 1.00m,
    (true, TimeBand.EveningRush, false) => 2.00m,
    (true, TimeBand.Overnight,   _)     => 0.75m,
    (false, _,                   _)     => 1.00m,
};


Eh bien, si vous regardez de plus près, vous pouvez également réduire cette option, en prenant le coefficient 1.0 dans le cas général:



public static decimal PeakTimePremiumFull(DateTime timeOfToll, bool inbound) =>
    (IsWeekDay(timeOfToll), GetTimeBand(timeOfToll), inbound) switch
{
    (true, TimeBand.Overnight, _) => 0.75m,
    (true, TimeBand.Daytime, _) => 1.5m,
    (true, TimeBand.MorningRush, true) => 2.0m,
    (true, TimeBand.EveningRush, false) => 2.0m,
    _ => 1.0m,
};


Au cas où, permettez-moi de clarifier: les comparaisons sont faites dans l'ordre dans lequel elles sont répertoriées. Lors de la première correspondance, la valeur correspondante est renvoyée et aucune autre comparaison n'est effectuée.

Les



tuples de mise à jour dans l'expression de commutateur peuvent également être utilisés dans C # 8.0. Le développeur sans valeur qui a écrit cet article est devenu un peu plus intelligent.





Et enfin, voici un autre exemple fou qui démontre la nouvelle syntaxe de correspondance avec les tuples et les propriétés d'objet:



public static bool IsAccessOkOfficial(Person user, Content content, int season) => 
    (user, content, season) switch 
{
    ({Type: Child}, {Type: ChildsPlay}, _)          => true,
    ({Type: Child}, _, _)                           => false,
    (_ , {Type: Public}, _)                         => true,
    ({Type: Monarch}, {Type: ForHerEyesOnly}, _)    => true,
    (OpenCaseFile f, {Type: ChildsPlay}, 4) when f.Name == "Sherlock Holmes"  => true,
    {Item1: OpenCaseFile {Type: var type}, Item2: {Name: var name}} 
        when type == PoorlyDefined && name.Contains("Sherrinford") && season >= 3 => true,
    (OpenCaseFile, var c, 4) when c.Name.Contains("Sherrinford")              => true,
    (OpenCaseFile {RiskLevel: >50 and <100 }, {Type: StateSecret}, 3) => true,
    _                                               => false,
};


Tout cela semble plutôt inhabituel. Pour une compréhension complète, je vous recommande de regarder la source , il y a un exemple complet du code.



Nouveau typage de cible, nouveau et fondamentalement amélioré



Il y a longtemps en C #, il est devenu possible d'écrire var au lieu d'un nom de type, car le type lui-même pouvait être déterminé à partir du contexte (en fait, cela s'appelle le typage cible). Autrement dit, au lieu de l'entrée suivante:



SomeLongNamedType variable = new SomeLongNamedType();


il est devenu possible d'écrire de manière plus compacte:



var variable = new SomeLongNamedType()


Et le compilateur devinera le type de variable lui-même. Au fil des ans, la syntaxe inverse a été implémentée:



SomeLongNamedType variable = new ();


Un merci spécial pour le fait que cette syntaxe fonctionne non seulement lors de la déclaration d'une variable, mais également dans de nombreux autres cas où le compilateur peut immédiatement deviner le type. Par exemple, lors du passage de paramètres à une méthode et du renvoi d'une valeur de la méthode:



var result = SomeMethod(new (2020,10,01));

//...

public Car SomeMethod(DateTime p)
{
    //...

    return new() { Passengers = 2 };
}


Dans cet exemple, lors de l'appel de SomeMethod, le paramètre du type DateTime est créé par la syntaxe abrégée. La valeur renvoyée par la méthode est créée de la même manière.



Là où il y aura vraiment un avantage à cette syntaxe, c'est lors de la définition des collections:



List<DateTime> datesList = new()
{
    new(2020, 10, 01),
    new(2020, 10, 02),
    new(2020, 10, 03),
    new(2020, 10, 04),
    new(2020, 10, 05)
};

Car[] cars = 
{
    new() {Passengers = 2},
    new() {Passengers = 3},
    new() {Passengers = 4}
};


L'absence de nécessité d'écrire le nom complet du type lors de la liste des éléments de la collection rend le code un peu plus propre.



Ciblez les opérateurs typés ?? et?:



L'opérateur ternaire?: A été amélioré en C # 9.0. Auparavant, cela nécessitait une conformité totale des types de retour, mais maintenant c'est plus intelligent. Voici un exemple d'expression qui n'était pas valide dans les versions antérieures de la langue, mais tout à fait légale dans la neuvième:



int? result = b ? 0 : null; // nullable value type


Auparavant, il était nécessaire de convertir explicitement de zéro en int? .. Maintenant, ce n'est pas nécessaire.



Dans la nouvelle version du langage, il est également permis d'utiliser la construction suivante:



Person person = student ?? customer; // Shared base type


Les types de client et d'étudiant, bien que dérivés de Person, sont techniquement différents. La version précédente du langage ne vous permettait pas d'utiliser une telle construction sans conversion de type explicite. Maintenant, le compilateur comprend parfaitement ce que cela signifie.



Remplacer le type de retour des méthodes



Dans C # 9.0, il était autorisé de remplacer le type de retour des méthodes remplacées. Il n'y a qu'une seule exigence: le nouveau type doit être hérité de l'original (covariant). Voici un exemple:



abstract class Animal
{
    public abstract Food GetFood();
    ...
}
class Tiger : Animal
{
    public override Meat GetFood() => ...;
}


Dans la classe Tiger, la valeur de retour de la méthode GetFood a été redéfinie de Food to Meat. Il est maintenant normal que la viande soit dérivée de la nourriture.



les propriétés init ne sont pas vraiment des membres en lecture seule



Une fonctionnalité intéressante est apparue dans la nouvelle version du langage: init-properties. Ce sont des propriétés qui ne peuvent être définies que lors de l'initialisation initiale de l'objet. Il semblerait que des élèves en lecture seule existent pour cela, mais en fait, ce sont des choses différentes qui vous permettent de résoudre différents problèmes. Pour comprendre quelle est la différence et quelle est la beauté des propriétés init, voici un exemple:



Person employee = new () {
    Name = "Paul McCartney",
    Company = "High Technologies Center",
    CompanyAddress = new () {
        Country = "Russia",
        City = "Izhevsk",
        Line1 = "246, Karl Marx St."
    }
}


Cette syntaxe pour déclarer une instance d'une classe est très pratique, surtout lorsqu'il y a plus d'objets parmi les propriétés de la classe. Mais cette syntaxe a des limites: les propriétés de classe correspondantes doivent être mutables . Cela est dû au fait que l'initialisation de ces propriétés se produit après l' appel au constructeur. Autrement dit, la classe Person de l'exemple doit être déclarée comme ceci:



class Person {
    //...
    public string Name {get; set;}
    public string Company {get; set;}
    public Address CompanyAddress {get; set;}
    //...
}


Cependant, en fait, la propriété Name est immuable. Actuellement, la seule façon de rendre cette propriété en lecture seule est de déclarer un setter privé:



class Person {
    //...
    public string Name {get; private set;}
    //...
}


Mais dans ce cas, nous perdons immédiatement la possibilité d'utiliser la syntaxe pratique pour déclarer une instance de classe en attribuant des valeurs aux propriétés entre accolades. Et nous ne pouvons définir la valeur de la propriété Name qu'en la transmettant en paramètres au constructeur de classe. Imaginez maintenant que la propriété CompanyAddress a également une signification immuable. En général, je me suis retrouvé dans une telle situation plusieurs fois, et j'ai toujours dû choisir entre deux maux:



  • constructeurs sophistiqués avec un tas de paramètres, mais toutes les propriétés de la classe en lecture seule;
  • syntaxe pratique pour créer un objet, mais toutes les propriétés de la classe lecture-écriture, et je dois m'en souvenir et ne pas les changer accidentellement quelque part.


À ce stade, quelqu'un pourrait rappeler les membres en lecture seule de la classe et suggérer de styliser la classe Person comme ceci:



class Person {
    //...
    public readonly string Name;
    public readonly string Company;
    public readonly string CompanyAddress;
    //...
}


Ce à quoi je réponds que cette méthode n'est pas seulement non conforme au Feng Shui, mais qu'elle ne résout pas non plus le problème de l'initialisation pratique: les membres en lecture seule peuvent également être définis uniquement dans le constructeur, comme les propriétés avec un setter privé.



Mais en C # 9.0, ce problème est résolu: si vous définissez une propriété en tant que propriété init, vous obtenez à la fois une syntaxe pratique pour créer un objet et une propriété qui est en fait immuable à l'avenir:



class Person {
    public string Name { get; init; }
    public string Company { get; init; }
    public Address CompanyAddress { get; init; }
}


À propos, dans init-properties, comme dans le constructeur, vous pouvez initialiser les membres de la classe en lecture seule, et vous pouvez écrire comme ceci:



public class Person
{
    private readonly string name;
       
    public string Name
    { 
        get => name; 
        init => name = (value ?? throw new ArgumentNullException(nameof(Name)));
    }
}


L'enregistrement est un DTO légalisé



Poursuivant le thème des propriétés immuables, nous arrivons à la principale, à mon avis, l'innovation du langage: le type d'enregistrement. Ce type est conçu pour créer facilement des structures immuables entières, pas seulement des propriétés. La raison de l'émergence d'un type distinct est simple: en travaillant selon tous les canons, nous créons constamment des DTO pour isoler différentes couches de l'application. Les DTO ne sont généralement qu'un ensemble de champs, sans aucune logique métier. Et, en règle générale, les valeurs de ces champs ne changent pas pendant la durée de vie de ce DTO.



.



DTO – Data Transfer Object. (DAL, BL, PL) - . «». -DTO' DAL BL, , DTO-, , DTO-, - ( HTML- JSON-).



— DTO-, - -, .



DTO- - . DTO-, AutoMapper - .



, DTO- .



Ainsi, après de très nombreuses années, les développeurs C # sont arrivés à l'amélioration vraiment nécessaire: ils ont légalisé les modèles DTO en tant que type d'enregistrement distinct.



Jusqu'à présent, tous les modèles DTO que nous avons créés (et nous les avons créés en grande quantité) étaient des classes ordinaires. Pour le compilateur et pour le runtime, elles n'étaient pas différentes de toutes les autres classes, bien qu'elles ne le soient pas au sens classique. Peu de personnes ont utilisé des structures pour les modèles DTO - ce n'était pas toujours acceptable pour diverses raisons.



Nous pouvons maintenant définir un enregistrement (ci-après appelé enregistrement) - une structure spéciale conçue pour créer des modèles DTO immuables. L'enregistrement prend une place intermédiaire entre les structures et les classes dans leur sens habituel. C'est à la fois une sous-classe et une superstructure. Un enregistrement est toujours un type de référence avec toutes les conséquences qui en découlent. Les enregistrements se comportent presque toujours comme une classe régulière, ils peuvent contenir des méthodes, ils permettent l'héritage (mais uniquement à partir d'autres enregistrements, pas d'objets, bien que si l'enregistrement n'hérite pas explicitement de quoi que ce soit, alors il hérite de l'objet aussi implicitement que tout en C # ) peut implémenter des interfaces. De plus, vous n'avez pas du tout besoin de rendre les enregistrements complètement immuables. Et où est alors le sens et quelle est la différence?



Créons simplement une entrée:



public record Person 
{
    public string LastName { get; }
    public string FirstName { get; }

    public Person(string first, string last) => (FirstName, LastName) = (first, last);
}


Et maintenant, voici un exemple d'utilisation:



Person p1 = new ("Paul", "McCartney");
Person p2 = new ("Paul", "McCartney");

System.Console.WriteLine(p1 == p2);


Cet exemple imprimera true sur la console. Si Person était une classe, alors false serait affiché sur la console car les objets sont comparés par référence: deux variables de référence ne sont égales que si elles font référence au même objet. Mais ce n'est pas le cas avec les enregistrements. Les enregistrements sont comparés par la valeur de tous leurs champs, y compris les champs privés.



Poursuivant l'exemple précédent, regardons ce code:



System.Console.WriteLine(p1);


Dans le cas d'une classe, nous recevrions le nom complet de la classe dans la console. Mais dans le cas des entrées, nous verrons ceci dans la console:



Person { LastName = McCartney, FirstName = Paul}


Le fait est que pour les enregistrements, la méthode ToString () est implicitement remplacée et n'affiche pas le nom du type, mais une liste complète des champs publics avec des valeurs. De même, pour les enregistrements, les opérateurs == et! = Sont implicitement redéfinis, ce qui permet de changer la logique de comparaison.



Jouons avec l'héritage record:



public record Teacher : Person
{
    public string Subject { get; }

    public Teacher(string first, string last, string sub)
        : base(first, last) => Subject = sub;
}


Créons maintenant deux articles de types différents et comparons-les:



Person p = new("Paul", "McCartney");
Teacher t = new("Paul", "McCartney", "Programming");

System.Console.WriteLine(p == t);


Bien que l'enregistrement Enseignant soit hérité de Person, les variables p et t ne seront pas égales, false sera affiché sur la console. En effet, la comparaison est effectuée non seulement pour tous les champs d'enregistrements, mais également pour les types, et les types ici sont clairement différents.



Et bien que la comparaison des types d'enregistrement hérités soit autorisée (mais inutile), la comparaison de différents types d'enregistrement en général n'est pas autorisée en principe:



public record Person
{
    public string LastName { get; }
    public string FirstName { get; }

    public Person(string first, string last) => (FirstName, LastName) = (first, last);
}

public record Person2
{
    public string LastName { get; }
    public string FirstName { get; }

    public Person2(string first, string last) => (FirstName, LastName) = (first, last);
}

// ...

Person p = new("Paul", "McCartney");
Person2 p2 = new("Paul", "McCartney");
System.Console.WriteLine(p == p2);    //  


Les entrées semblent être les mêmes, mais il y aura une erreur de compilation sur la dernière ligne. Vous ne pouvez comparer que des enregistrements du même type ou des types hérités.



Une autre fonctionnalité intéressante des enregistrements est le mot-clé with, qui facilite la création de modifications de vos modèles DTO. Jetez un œil à un exemple:



Person me = new("Steve", "Brown");
Person brother = me with { FirstName = "Paul" };


Dans cet exemple, pour l'enregistrement Brother, les valeurs de tous les champs seront remplies à partir de l'enregistrement moi, à l'exception du champ FirstName - il sera remplacé par Paul.



Jusqu'à présent, vous avez vu la manière classique de créer des enregistrements - avec des définitions complètes des constructeurs, des propriétés, etc. Mais maintenant, il y a aussi une voie laconique:



public record Person(string FirstName, string LastName);

public record Teacher(string FirstName, string LastName,
    string Subject)
    : Person(FirstName, LastName);

public sealed record Student(string FirstName,
    string LastName, int Level)
    : Person(FirstName, LastName);


Vous pouvez définir des enregistrements en abrégé et le compilateur créera les propriétés et le constructeur pour vous. Cependant, cette fonctionnalité a une fonctionnalité supplémentaire - vous pouvez non seulement utiliser une notation abrégée pour définir des propriétés et un constructeur, mais en même temps, vous pouvez ajouter votre propre méthode à l'entrée:



public record Pet(string Name)
{
    public void ShredTheFurniture() =>
        Console.WriteLine("Shredding furniture");
}

public record Dog(string Name) : Pet(Name)
{
    public void WagTail() =>
        Console.WriteLine("It's tail wagging time");

    public override string ToString()
    {
        StringBuilder s = new();
        base.PrintMembers(s);
        return $"{s.ToString()} is a dog";
    }
}


Dans ce cas, les propriétés et le constructeur des enregistrements seront également créés automatiquement. De moins en moins de code standard, mais uniquement applicable aux postes. Cela ne fonctionne pas pour les classes et les structures.



En plus de tout ce qui a déjà été dit, le compilateur peut également créer automatiquement un déconstructeur pour les enregistrements:



var person = new Person("Bill", "Wagner");

var (first, last) = person; //    
Console.WriteLine(first);
Console.WriteLine(last);


Cependant, au niveau IL, les enregistrements sont toujours une classe. Cependant, il y a un soupçon pour lequel aucune confirmation n'a encore été trouvée: bien sûr, au niveau de l'exécution, les enregistrements seront extrêmement optimisés quelque part. Très probablement, en raison du fait qu'il sera connu à l'avance qu'un enregistrement particulier est immuable. Cela ouvre des opportunités d'optimisation, au moins dans un environnement multi-thread, et le développeur n'a même pas besoin de faire des efforts particuliers pour cela.



En attendant, nous réécrivons tous les modèles DTO des classes aux enregistrements.



Générateurs de sources .NET



Le générateur de source (ci-après appelé simplement un générateur) est une fonctionnalité assez intéressante. Un générateur est un morceau de code qui est exécuté au stade de la compilation, qui a la capacité d'analyser le code déjà compilé et peut générer du code supplémentaire qui sera également compilé. Si ce n'est pas tout à fait clair, voici un exemple plutôt pertinent où un générateur peut être en demande.



Imaginez une application Web C # / .NET que vous écrivez dans ASP.NET Core. Lorsque vous lancez cette application, il y a une énorme quantité de travail en arrière-plan d'initialisation sur l'analyse de quoi cette application est faite et ce qu'elle devrait faire du tout. La réflexion est utilisée avec frénésie. En conséquence, le délai entre le lancement de l'application et le début du traitement de la première requête peut être extrêmement long, ce qui est inacceptable dans les services fortement chargés. Le générateur peut aider à réduire ce temps: même au stade de la compilation, il peut analyser votre application déjà compilée et générer en plus le code nécessaire qui l'initialisera beaucoup plus rapidement au démarrage.



Il existe également un assez grand nombre de bibliothèques qui utilisent la réflexion pour déterminer au moment de l'exécution les types d'objets utilisés (parmi eux, il existe de nombreux packages Nuget). Cela ouvre une énorme marge d'optimisation à l'aide de générateurs, et les auteurs de cette fonctionnalité s'attendent à des améliorations appropriées de la part des développeurs de la bibliothèque.



Les générateurs de code sont un nouveau sujet et trop inhabituel pour entrer dans le cadre de cet article. De plus, vous pouvez voir un exemple du plus simple "Hello, world!" générateur dans cette revue .



Il existe deux nouvelles fonctionnalités associées aux générateurs de code, qui sont décrites ci-dessous.



Méthodes partielles



Les classes partielles en C # existent depuis longtemps, leur objectif initial est de séparer le code généré par un certain concepteur du code écrit par le programmeur. Des méthodes partielles ont été adaptées dans C # 9.0. Ils ressemblent à quelque chose comme ceci:



public partial class MyClass
{
    public partial int DoSomeWork(out string p);
}
public partial class MyClass
{
    public partial int DoSomeWork(out string p)
    {
        p = "test";
        System.Console.WriteLine("Partial method");
        return 5;
    }
}


Cet exemple de substitution démontre que les méthodes partielles ne sont essentiellement pas différentes des méthodes ordinaires: elles peuvent renvoyer des valeurs, elles peuvent accepter des variables externes et elles peuvent avoir des modificateurs d'accès.



D'après les informations disponibles, les méthodes partielles seront étroitement liées aux générateurs de code, où elles sont destinées à être utilisées.



Initialiseurs de module



Il y a trois raisons pour introduire cette fonctionnalité:



  • Permettre aux bibliothèques d'avoir une sorte d'initialisation unique au démarrage avec une surcharge minimale et aucun besoin explicite pour l'utilisateur d'appeler quoi que ce soit;
  • la fonctionnalité existante des constructeurs statiques n'est pas très adaptée à ce rôle, car le temps réel doit d'abord savoir: si une classe avec un constructeur statique est utilisée (ce sont les règles), et cela donne des délais mesurables;
  • les générateurs de code doivent avoir une sorte de logique d'initialisation qui n'a pas besoin d'être appelée explicitement.


En fait, le dernier point semble être devenu déterminant pour la fonctionnalité à inclure dans la version. En conséquence, nous avons proposé un nouvel attribut dont nous avons besoin pour recouvrir la méthode d'initialisation:



using System.Runtime.CompilerServices;
class C
{
    [ModuleInitializer]
    internal static void M1()
    {
        // ...
    }
}


Il y a quelques restrictions sur la méthode:



  • il doit être statique;
  • il ne doit avoir aucun paramètre;
  • il ne devrait rien renvoyer;
  • cela ne devrait pas fonctionner avec des génériques;
  • il doit être accessible depuis le module conteneur, c'est-à-dire:

    • il doit être interne ou public
    • il n'est pas nécessaire que ce soit une méthode locale


Et cela fonctionne comme ceci: dès que le compilateur trouve toutes les méthodes marquées avec l'attribut ModuleInitializer, il génère un code spécial qui les appelle toutes. L'ordre d'appel des méthodes d'initialisation ne peut pas être spécifié, mais il sera le même à chaque compilation.



Conclusion



Ayant déjà publié l'article, nous avons remarqué qu'il est plus consacré à l'actualité en langage C # 9.0 qu'à l'actualité de .NET lui-même. Mais ça s'est bien passé.



All Articles