Nieuwe features in C#9

Met de komst van .NET 5 en C# 9.0 zijn er heel veel waardevolle toevoegingen gedaan aan de programmeertaal. In deze whitepaper gaan we in op de belangrijkste toevoegingen en om C# 9.0 te kunnen gebruiken dien je eerst de .NET 5 runtime of SDK te installeren. Ga hiervoor naar dotnet.microsoft.com/download. Na installatie zal C# 9.0 de standaard C# versie zijn bij elk nieuw project gebaseerd op .NET 5.

Auteurs: Eric van Olst & Patrick Vroegh

PATTERN MATCHING (UITBREIDING)

Waarom?

De syntax van ontwikkeltalen is al sinds jaren het onderwerp van discussies tussen ontwikkelaars. Leesbaarheid, efficiëntie en flexibi liteit is slechts een selectie van de tientallen argumenten die jouw mening vormen over het al dan niet geslaagd zijn van een syntax. Martin Fowler geeft een – zoals we van hem gewend zijn – zeer zinvolle bijdrage in deze discussie met zijn bekende uitspraak: Any fool can write code that a computer can understand. Good programmers write code that
humans can understand. Met die uitspraak in het achterhoofd heeft Microsoft een aantal vereenvoudigingen aan de syntax van if en switch doorgevoerd. Deze onderdelen worden door ons veel gebruikt om complexe condities uit te werken. Sinds C# 7.0 helpt Pattern Matching ons om de code die we opleveren beter leesbaar en dus minder foutgevoelig te maken. Ook in C# 9.0 zijn er weer enkele nieuwe mogelijkheden toegevoegd.

Hoe?

Logical Pattern. Om meer complexe condities goed leesbaar te houden is het Logical Pattern toegevoegd aan C# 9.0. Hiermee kunnen we gebruik maken van and, or en not in onze statements, en het herhalen van de parameter waarmee vergeleken moet worden kan achterwege worden gelaten. Voorbeeld 1. Dit is een krachtig mechanisme dat kan worden gebruikt in if-statements zoals in bijgaand voorbeeld. De volgorde waarin de controles worden uitgevoerd is volstrekt logisch, maar in
sommige gevallen zul je toch aan deze volgorde moeten wennen. Vergeet dus niet om unittests toe te voegen met voldoende testsets om er zeker van te zijn dat je functie in alle gevallen het gewenste gedrag vertoont. Het Logical Pattern komt zelfs beter tot zijn recht wanneer dit wordt gecombineerd in switch expressions,
zoals we hierna zullen zien bij het Relational Pattern.

Voorbeeld 1

Relational Pattern

In C# 8.0 zijn switch expressions toegevoegd. Hiermee was het mogelijk om direct een returnwaarde als resultaat van een aantal voorwaarden te definiëren. De syntax
bevatte veel onnodige herhalingen en was daarom nog niet in alle gevallen even leesbaar, zoals het onderstaande voorbeeld illustreert.

Voorbeeld 2

In C# 9.0 kunnen we de bovenstaande syntax verder verfijnen door gebruik te maken van het Relational Pattern. Hierdoor kunnen we rechtstreeks gebruik maken van Relational operators zoals < of >=. Toepassing leidt tot het volgende resultaat:


Voorbeeld 3

In deze syntax ligt de nadruk op de logica en het resultaat, en veel minder op het achterhalen van de juiste objecten. We zien ook dat het gebruik van het
Logical Pattern ook daadwerkelijk de leesbaarheid van de code vergroot: in één oogopslag is nu duidelijk dat er een selectie gemaakt wordt voor iemand tussen de 12 en 60. Dit voorbeeld leent zich daarnaast bom de switch expression, die in C# 8.0 al is geïntroduceerd nogmaals onder de aandacht te brengen. Bij de switch expression staat de variabele vóór het switch keyword, de switch-onderdelen case en : zijn vervangen door de bekende =>.

Als laatste is de body is geen statement meer, maar een expressie. De expression is in het onderstaand voorbeeld uitgewerkt. Hierbij zien we dat we ons niet hoeven te beperken tot property checking, ook Type check ing behoort tot de mogelijkheden.

Voorbeeld 4

Type Pattern

Met het Type Pattern kun je met een vereenvoudigde syntax conditie checks op type en properties uitvoeren. De nieuwe schrijfwijze in C# 9.0 is compact en overzichtelijk, zoals in onderstaand voorbeeld wordt getoond.

Voorbeeld 5

Wanneer de parameter person niet van het type PersonRecord is ontstaat er geen exception maar wordt de evaluatie van het if-statement afgebroken. In het andere geval (person is van het type PersonRecord) wordt het resultaat bepaald op basis van de property Age van person. Wees je er in dit voorbeeld goed van bewust dat deze code géén Null ReferenceException oplevert wanneer de parameter person null is.

Verder?

Veel van onze logica in onze applicaties is vormgegeven door implementaties van if-then-else en switch state ments. Na verloop van tijd kunnen deze constructies steeds complexer en onoverzichtelijker worden. Met de nieuwe mogelijkheden van pattern matching kunnen we deze bestaande code relatief eenvoudig refactoren naar beter leesbare en dus beter onderhoudbare code. Maar natuurlijk geldt zoals altijd: bedenk of deze complexe constructies gezien kunnen worden als code smell voor het overtreden van belangrijke SOLID principes, zoals het Open/Closed of S0ingle Responsibility principe. Overweeg in die gevallen vooral ook de toepassing van nette OO oplossingen, zoals het inzetten van encapsulation en de vele design patterns die beschikbaar zijn.

Top level statements

Waarom? Elke .NET assembly die uitvoerbaar is, dient een entrypoint te hebben anders heeft de applicatie geen startpunt. Dit startpunt is de welbekende Main methode. Zelfs een simpele console applicatie heeft een Main methode nodig om te kunnen functioneren. Het requirement van zo’n Main methode maakt dat C# een ietwat minder toegankelijke programmeertaal is voor beginners. Wanneer beginners de volgende code onder ogen krijgen,  kan dat best intimiderend overkomen:

Voorbeeld 6

Het enige wat bovenstaande code moet doen is de tekst “Hello World!” tonen op het scherm, maar voordat de
applicatie zover is moet er dus eerst heel veel “ceremonie” plaatsvinden:
using statements.
>namespace  definitie.
>class  definitie.
>Main  methode definitie (het startpunt).
De nieuwe C# 9.0 feature “Top-level Statements” maakt deze ceremonie overbodig en dus hopelijk ook wat meer toegankelijk voor beginners!

Voorbeeld 7

Hoe?

De volgende code is vanaf C# 9.0 uitvoerbaar geworden op zichzelf, zonder extra code: zie voorbeeld 7. Zoals je kan zien is deze code van alle “ceremonie” ontdaan, dus geen class  definitie en geen Main  methode definitie meer. Je kunt je vast voorstellen dat dit veel beter te begrijpen is voor een beginner dan de eerdere voorbeeldcode. using statements zijn gewoon nog te gebruiken om te voorkomen dat je steeds de fully qualified type namen moet  specificeren:

Voorbeeld 8

Verder?

Ondanks dat er geen Main methode is gedefinieerd, ben je nog steeds in staat om de args parameter te gebruiken om de command-line parameters uit te kunnen lezen:

Voorbeeld 9

Wanneer de Top- level Statements het await keyword bevatten, zal de C# compiler onderwater automatisch een async entrypoint maken:

Voorbeeld 10

De mogelijkheid bestaat nog steeds om een int waarde terug te geven, welke aangeeft wat de zogenaamde exitcode is van je applicatie.

Voorbeeld 11

Maar?

Bij het gebruik maken van Top Level Statements zijn er wel een tweetal beperkingen waar je rekening mee moet houden.

  • 1. Top Level Statements kunnen slechts in één bestand onder het project aanwezig zijn.
  • 2. In principe geen nieuwe regel, maar wanneer er Top Level Statements aanwezig zijn in een project, mogen er geen andere entrypoints bestaan.

Immutability verbeteringen

Stel je programmeert een object waarvan je wilt dat de waardes niet gewijzigd worden nadat het  object geinstantieerd is. Dit was in het verleden lastig in code te realiseren. Je kunt met private  setters werken, maar dan zijn objecten vaak niet goed te serialiseren. In de praktijk kiezen de meeste ontwikkelaars er dan maar voor om het object “mutable” te houden. Het vervelende is dat de code dan niet optimaal de intentie van de programmeur weergeeft. Dit was altijd al een probleem bij bijvoorbeeld data transfer objects, maar met de populariteit van domain driven design en de bijbehorende value objects, wordt het nog belangrijker om een betere oplossing te vinden. Microsoft moet dit ook gedacht hebben, toen ze in C#9 de init-only setters en de recordtypes introduceerde. Deze nieuwe code constructies maken het mogelijk om op eenvoudige manier objecten te maken die immutable zijn, en die op basis van hun waardes met elkaar vergeleken kunnen worden.

Init only properties

Waarom? De Init Only Setters zijn aan C# toegevoegd om de ontwikkelaars een betere ervaring te geven met het maken en gebruiken van immutable classes of structs, door een extra moment te introduceren waarop data gemodificeerd mag worden. Voorheen werd immutability verkregen door members readonly te maken of door properties niet te voorzien van een public setter. Het probleem wat hiermee ontstond, was dat je de data van het object alleen kon initialiseren via  constructor parameters. Ook middels de in C# 3.0 geïntroduceerde object initializers, was het niet mogelijk om de data voor het object bij initialisatie in te stellen:

Voorbeeld 12
Voorbeeld 13

Hoe?

In C# 9.0 is het nu wel mogelijk om immutable properties voor een object te initialiseren middels de object initializer. Die doe je door de setter van de property te vervangen door het nieuwe init keyword: Zie voorbeeld 13. Hiermee wordt dus voorkomen dat je expliciet constructor parameters voor alle properties moet aanmaken. Het geeft de ontwikkelaar dus veel vrijheid in het initialiseren van objecten.

Verder?

Het kunnen instellen van immutable properties middels de object initializer is verreweg het grootste voordeel wat de Init Only Setters feature biedt. De nieuwe  feature kan echter breder ingezet worden, onder andere op de volgende manieren:
> Setters van properties kunnen nu in de instance constructor van de afgeleide class ook benaderd worden: (14)
> Init Only Setters kunnen ook benaderd worden vanuit de body van een andere Init Only Setter: (15)

Voorbeeld 14
Voorbeeld 15

“Any fool can write code that a computer can understand. Good programmers write code that humans can understand.”

Record types

Waarom? In deel 2 van deze serie is al gewezen op init-only setters. Op een eenvoudige manier kun je daarmee aangeven dat properties als immutable behandeld  moeten worden. Met het nieuwe keyword record krijg je als programmeur de mogelijkheid om snel en overzichtelijk een complete class immutable te maken. Je kunt dit record dan instantiëren, waarbij de data initieel wordt gezet en daarna niet meer te wijzigen is. Dit levert je een aantal voordelen op. Denk aan toepassing  van deze record types in een multi-threaded applicatie. Omdat het object immutable is, met andere woorden niet kan worden benaderd om wijzigingen door
te voeren, kan er geen race condition optreden waarbij thread 1 gegevens leest terwijl thread 2 gegevens schrijft. Je code kan worden vereenvoudigd zonder risico op deadlocks of nog erger, berekeningen met een onverwachte uitkomst. Een ander groot voordeel van een immutable record is dat je de betrouwbaarheid van je unittesten kunt vergroten. Stel je een applicatie voor waarbij een “ouderwetse” DTO-class door diverse methodes in een keten als parameter wordt gebruikt.  Wanneer de inhoud van deze class mutable is, zul je bij het schrijven van een test op één van die methodes uit moeten zoeken of er methodes bestaan die gegevens
in jouw DTO kunnen wijzigen. Dit kan namelijk impact hebben op het aantal scenario’s dat je zult moeten testen. Een immutable record maakt het onmogelijk dat  gegevens worden gewijzigd, waardoor je veilig deze uitzoekklus over kunt slaan.

Voorbeeld

Hoe?

Declareren en instantiëren C# 9.0 biedt twee mogelijkheden om een record te declareren. Het meest eenvoudig is de volgende manier, waarbij je positional  arguments toepast: Voorbeeld 16. Deze methode is intuïtief en overzichtelijk. De C# compiler voegt boilerplate-code zoals een constructor met parameters en een deconstructor toe zodat je direct een Object van het type Car kunt aanmaken en gebruik kan maken van enkele handige uitbreidingen. Uit de intellisense blijkt dat
de aangegeven property LengthInCm gebruikt maakt van init als setter en dus inderdaad immutabel is. Er is géén parameterloze constructor aanwezig, je bent dus verplicht om de parameters via de constructor te vertrekken. Voorbeeld 17. Veel controle over Racecar heb je natuurlijk niet, nuttige zaken als het inbouwen van validatie of het aangeven van een default value is zo niet mogelijk. Daarom is ook de meer klassieke ogende methode beschikbaar waardoor je bijvoorbeeld berekende properties of methodes kunt toevoegen. Voorbeeld 18.

Voorbeeld 17
Voorbeeld 18

Vergelijken van records

Een mooie eigenschap van een record is dat het zuiver gezien een reference type is, maar dat het zich gedraagt als een value type. Dit heeft een belangrijk effect op het vergelijken van instances van records. Bij de vergelijking wordt namelijk – net als bij een echt value type – niet gekeken of de instances naar hetzelfde object  verwijzen, maar of het type en de inhoud van de record instanties overeenkomen. De bijgaande test brengt dit mooi in beeld. Er worden 2 aparte instanties van  PersonRecord aangemaakt en vervolgens vergeleken via de ==-operator en de AreEqual methode. Met classes zijn we gewend dat dit verschillende objecten zijn en daarom verwachten we dat de test faalt. Maar omdat records zich gedragen als value types slaagt deze test.

Voorbeeld 19

Muteren van een record

Hoewel een record immutable is, is het wel mogelijk een kopie te maken van een instantie en daarbij kleine wijzigingen aan te brengen met behulp van het keyword with op de volgende manier:

Voorbeeld 20

Verder?

Bij het record type krijg je nog een aantal andere methodes tot je beschikking.

ToString()

Deze methode levert automatisch de inhoud van het type en alle publieke properties.

Voorbeeld 21
Deconstructor()

De deconstructor geeft je de mogelijkheid om op de volgende manier de gegevens uit je record te verzamelen.

Voorbeeld 22