Kihagyás

2. Nyelvi eszközök

A gyakorlat célja

A gyakorlat során a hallgatók megismerkednek a legfontosabb modern, a .NET környezetben is rendelkezésre álló nyelvi eszközökkel. Feltételezzük, hogy a hallgató a korábbi tanulmányai során elsajátította az objektum-orientált szemléletmódot, és tisztában van az objektum-orientált alapfogalmakkal. Jelen gyakorlat során azokra a .NET-es nyelvi elemekre koncentrálunk, amelyek túlmutatnak az általános objektum-orientált szemléleten, ugyanakkor nagyban hozzájárulnak a jól átlátható és könnyen karbantartható kód elkészítéséhez. Ezek a következők:

  • Tulajdonság (property)
  • Delegát (delegate, metódusreferencia)
  • Esemény (event)
  • Attribútum (attribute)
  • Lambda kifejezés (lambda expression)
  • Generikus típus (generic type)
  • Néhány további nyelvi konstrukció

Kapcsolódó előadások: a 2. előadás és a 3. előadás eleje – Nyelvi eszközök.

Előfeltételek

A gyakorlat elvégzéséhez szükséges eszközök:

  • Visual Studio 2022

Gyakorlat Linuxon vagy macOS alatt

A gyakorlat anyag alapvetően Windowsra és Visual Studiora készült, de az elvégezhető más operációs rendszereken is más fejlesztőeszközökkel (pl. VS Code, Rider, Visual Studio for Mac), vagy akár egy szövegszerkesztővel és CLI (parancssori) eszközökkel. Ezt az teszi lehetővé, hogy a példák egy egyszerű Console alkalmazás kontextusában kerülnek ismertetésre (nincsenek Windows specifikus elemek), a .NET SDK pedig támogatott Linuxon és macOS alatt. Hello World Linuxon

Bevezető

Kitekintő részek

Jelen útmutató több helyen is bővített ismeretanyagot, illetve extra magyarázatot ad meg jelen megjegyzéssel egyező színnel keretezett és ugyanilyen ikonnal ellátott formában. Ezek hasznos kitekintések, de nem képezik az alap tananyag részét.

Megoldás

A kész megoldás letöltése

❗ Lényeges, hogy a labor során a laborvezetőt követve kell dolgozni, tilos (és értelmetlen) a kész megoldás letöltése. Ugyanakkor az utólagos önálló gyakorlás során hasznos lehet a kész megoldás áttekintése, így ezt elérhetővé tesszük.

A megoldás GitHubon érhető el itt. A legegyszerűbb mód a letöltésére, ha parancssorból a git clone utasítással leklónozzuk a gépünkre:

git clone https://github.com/bmeviauab00/lab-nyelvieszkozok-megoldas

Ehhez telepítve kell legyen a gépre a parancssori git, bővebb információ itt.

0. Feladat - var kulcsszó - Implicit típusú lokális változók (implicitly typed local variables)

Egy egyszerű, bemelegítő feladattal kezdünk. A következő példában egy Person nevű osztályt fogunk elkészíteni, mely egy személyt reprezentál.

  1. Hozzunk létre egy új C# konzolos alkalmazást. .NET alapút (vagyis ne .NET Framework-öset):
    • Erre az első gyakorlat alkalmával láttunk példát, leírása annak útmutatójában szerepel.
    • A "Do not use top level statements" jelölőnégyzetet pipáljuk be a projekt létrehozás során.
  2. Adjunk hozzá egy új osztályt az alkalmazásunkhoz Person néven. (Új osztály hozzáadásához a Solution Explorerben kattintsunk jobb egérgombbal a projekt fájlra és válasszuk az Add / Class menüpontot. Az előugró ablakban a létrehozandó fájl nevét módosítsuk Person.cs-re, majd nyomjuk meg az Add gombot.)
  3. Tegyük az osztályt publikussá. Ehhez az osztály neve elé be kell írni a public kulcsszót. Erre a módosításra itt valójában még nem volna szükség, ugyanakkor egy későbbi feladat már egy publikus osztályt fog igényelni.

    public class Person
    {
    }
    
  4. Egészítsük ki a Program.cs fájl Main függvényét, hogy kipróbálhassuk az új osztályunkat.

    static void Main(string[] args)
    {
        Person p = new Person();
    }
    
  5. A lokális változók típusának explicit megadása helyett használhatjuk a var kulcsszót is:

    static void Main(string[] args)
    {
        var p = new Person();
    }
    

    Ezt implicitly typed local variables-nek, magyarul implicit típusú lokális változó-nak nevezzük. Ilyenkor a fordító a kontextusból, az egyenlőségjel jobb oldalából megpróbálja kitalálni a változó típusát, fenti esetben ez egy Person lesz. Fontos, hogy ettől a nyelv még statikusan tipusos marad (tehát nem úgy működik mint a JavaScript-es var kulcsszó), mert a p változó típusa a későbbiekben nem változhat meg, ez csak egy egyszerű szintaktikai édesítőszer annek érdekében, hogy tömörebben tudjunk lokális változókat definiálni (ne kelljen a típust "duplán", az = bal és jobb oldalán is megadni).

    Target-typed new expressions

    Egy másik megközelítés lehet a a C# 9-ben megjelent Target-typed new expressions, ahol a new operátor esetén hagyható el a típus, ha az a fordító által kitalálható a kontextusból (pl.: értékadás bal oldala, paraméter típusa stb.). A fenti Person konstruktorunk a következőképpen nézne ki:

    Person p = new();
    

    Ennek a megközelítésnek az előnye a var-ral szemben, hogy tagváltozók esetében is alkalmazható.

1. Feladat – Tulajdonság (property)

A tulajdonságok segítségével tipikusan (de mint látni fogjuk, nem kizárólagosan) osztályok tagváltozóihoz férhetünk hozzá szintaktika tekintetében hasonló módon, mintha egy hagyományos tagváltozót érnénk el. A hozzáférés során azonban lehetőségünk van arra, hogy az egyszerű érték lekérdezés vagy beállítás helyett metódusszerűen implementáljuk a változó elérésének a módját, sőt külön külön is meghatározhatjuk a lekérdezés és a beállítás láthatóságát.

Tulajdonság szintaktikája

A következő példában egy Person nevű osztályt fogunk elkészíteni, mely egy személyt reprezentál. Két tagváltozója van, name és age. A tagváltozókhoz közvetlenül nem férhetünk hozzá (mivel privátok), csak a Name, illetve Age publikus tulajdonságokon keresztül kezelhetjük őket. A példa jól szemlélteti, hogy a .NET-es tulajdonságok egyértelműen megfelelnek a C++-ból és Java-ból már jól ismert SetX(…) illetve GetX() típusú metódusoknak, csak itt ez a megoldás egységbezártabb módon nyelvi szinten támogatott.

  1. Az előző feladatban bevezetett Person osztályon belül hozzunk létre egy int típusú age nevű tagváltozót és egy ezt elérhetővé tevő Age tulajdonságot.

    public class Person
    {
        private int age;
        public int Age
        {
            get { return age; }
            set { age = value; }
        }
    }
    

    Visual Studio snippetek

    A laboron ugyan a gyakorlás kedvéért kézzel gépeltük be a teljes tulajdonságot, de a Visual Studio-ban a gyakran előforduló kódrészletek létrehozására úgynevezett code snippetek állnak rendelkezésünkre, melyekkel a gyakori nyelvi konstrukciókat tudjuk sablonszerűen felhasználni. A fenti property kódrészletet a propfull snippettel tudjuk előcsalni. Gépeljük be a snippet nevét (propfull), majd addig nyomjuk a Tab billentyűt amíg a snippet nem aktiválódik (tipikusan 2x).

    Említésre méltó egyéb snippetek a teljesség igénye nélkül:

    • ctor: konstruktor
    • for: for ciklus
    • foreach: foreach ciklus
    • prop: auto property (lásd később)
    • switch: switch utasítás
    • cw: Console.WriteLine

    Ilyen snippeteket egyébként mi is készíthetünk.

  2. Egészítsük ki a Program.cs fájl Main függvényét, hogy kipróbálhassuk az új tulajdonságunkat.

    static void Main(string[] args)
    {
        var p = new Person();
        p.Age = 17;
        p.Age++;
        Console.WriteLine(p.Age);
    }
    
  3. Futtassuk a programunkat (F5)

    Láthatjuk, hogy a tulajdonság a tagváltozókhoz hasonlóan használható. A tulajdonság lekérdezése esetén a tulajdonságban definiált get rész fog lefutni, és a tulajdonság értéke a return által visszaadott érték lesz. A tulajdonság beállítása esetén a tulajdonságban definiált set rész fog lefutni, és a speciális value változó értéke ebben a szakaszban megfelel a tulajdonságnak értékül adott kifejezéssel.

    Figyeljük meg a fenti megoldásban azt, hogy milyen elegánsan tudjuk egy évvel megemelni az ember életkorát. Java, vagy C++ kódban egy hasonló műveletet a p.setAge(p.getAge() + 1) formában írhattunk volna le, amely jelentősen körülményesebb és nehezen olvashatóbb szintaktika a fentinél. A tulajdonságok használatának legfőbb hozadéka, hogy kódunk szintaktikailag tisztább lesz, az értékadások/lekérdezések pedig az esetek többségében jól elválnak a tényleges függvényhívásoktól.

  4. Győződjünk meg róla, hogy a programunk valóban elvégzi a get és set részek hívását. Ehhez helyezzünk töréspontokat (breakpoint) a getter és setter blokkok belsejébe a kódszerkesztő bal szélén látható szürke sávra kattintva.

  5. Futtassuk a programot lépésről lépésre. Ehhez a programot F5 helyett az F11 billentyűvel indítsuk, majd az F11 további megnyomásaival engedjük sorról sorra a végrehajtást.

    Láthatjuk, hogy a programunk valóban minden egyes alkalommal meghívja a gettert, amikor értéklekérdezés, illetve a settert, amikor értékbeállítás történik.

  6. A setter függvények egyik fontos funkciója, hogy lehetőséget kínálnak az értékvalidációra. Egészítsük ki ennek szellemében az Age tulajdonság setter-ét.

    public int Age
    {
        get { return age; }
        set 
        {
            if (value < 0)
                throw new ArgumentException("Érvénytelen életkor!");
            age = value; 
        }
    }
    

    Figyeljük meg, hogy míg az egyszerű getter és setter esetében az értéklekérdezést/beállítást egy sorban tartjuk, addig komplexebb törzs esetén már több sorra tördeljük.

  7. Az alkalmazás teszteléséhez rendeljünk hozzá negatív értéket az életkorhoz a Program osztály Main függvényében.

    p.Age = -2;
    
  8. Futtassuk a programot, győződjünk meg arról, hogy az ellenőrzés helyesen működik, majd hárítsuk el a hibát azzal, hogy pozitívra cseréljük a beállított életkort.

    p.Age = 2;
    

Autoimplementált tulajdonság (auto-implemented property)

A mindennapi munkánk során találkozhatunk a tulajdonságoknak egy sokkal tömörebb szintaktikájával is. Ez a szintaktika akkor alkalmazható, ha egy olyan tulajdonságot szeretnénk létrehozni, melyben:

  • nem szeretnénk semmilyen kiegészítő logikával ellátni a getter és setter metódusokat,
  • nincs szükségünk a privát tagváltozó közvetlen elérésére.

Erre nézzünk a következőkben példát.

  1. Egészítsük ki a Person osztályunkat egy ilyen, ún. „autoimplementált” tulajdonsággal (auto-implemented property). Készítsünk egy string típusú Name nevű tulajdonságot.

    public string Name { get; set; }
    

    A szintaktikai különbség a korábbiakhoz képest: a get és a set ágnak sem adtunk implementációt (nincsenek kapcsos zárójelek). Autoimplemetált tulajdonság esetén a fordító egy rejtett, kódból nem elérhető változót generál az osztályba, mely a tulajdonság aktuális értékének tárolására szolgál. Hangsúlyozandó, hogy ez nem a korábban bevezetett name tagváltozót állítja és kérdezi le (az ki is törölhetnénk), hanem egy rejtett, új változón dolgozik!

  2. Most ellenőrizzük a működését a Main függvény kiegészítésével.

    static void Main(string[] args)
    {
        // ...
        p.Name = "Luke";
        // ...
        Console.WriteLine(p.Name);
    }
    

Alapértelmezett érték (default value)

Az autoimplementált tulajdonságok esetében megadható a kezdeti értékük is a deklaráció során.

  1. Adjunk kiinduló értéket a Name tulajdonságnak.

    public string Name { get; set; } = "anonymous";
    

Tulajdonságok láthatósága

A tulajdonságok nagy előnye a teljesen szabad implementáció mellett, hogy a getter és a setter láthatóságát külön külön is lehet állítani.

  1. Állítsuk a Name tulajdonság setterének a láthatóságát privátra.

    public string Name { get; private set; }
    

    Ilyenkor a Program osztályban fordítási hibát kapunk a p.Name = "Luke"; utasításra. Az alapvető szabály az, hogy a getter és a setter örökli a property láthatóságát, mely tovább szűkíthető, de nem lazítható. A láthatóság szabályozása autoimplementált és nem autoimplementált tulajdonságok esetén is használható.

  2. Állítsuk vissza a láthatóságot (távolítsuk el a private kulcsszót a Name tulajdonság settere elől), hogy megszűnjön a fordítási hiba.

Csak olvasható tulajdonság (readonly property)

A setter elhagyható, így egy olyan tulajdonságot kapunk, mely csak olvasható. Autoimplementált tulajdonság esetén ennek is adható kezdőérték: erre csak konstruktorban, vagy alapértelmezett értékkel való ellátással (lásd fent) van lehetőség, ellentétben a privát setterrel rendelkező tulajdonságokkal, melyek settere bármely, az osztályban található tagfüggvényből hívható.

Csak olvasható tulajdonság definiálását a következő kódrészletek illusztrálják (a kódunkba NE vezessük be):

a) Autoimplementált eset

public string Name { get; }

b) Nem autoimplementált eset

private string name;
...
public string Name { get {return name; } }

Számított érték (calculated value)

A csak getterrel rendelkező tulajdonságoknak van még egy használati módja. Valamilyen számított érték meghatározására is használható, mely mindig kiszámol egy megadott logika alapján egy értéket, de a "csak olvasható tulajdonság"-gal szemben nincs mögötte közvetlenül a tulajdonsághoz tartozó adattag. Ezt a következő kódrészlet illusztrálja (a kódunkba NE vezessük be):

public int AgeInDogYear { get { return Age * 7; } }

2. Feladat – Delegát (delegate, metódusreferencia)

Forduljon a kód!

A további feladatok építeni fognak az előző feladatok végeredményeire. Ha programod nem fordul le, vagy nem megfelelően működik, jelezd ezt a gyakorlatvezetődnek a feladatok végén, és segít elhárítani a hibát.

A delegátok típusos metódusreferenciákat jelentenek .NET-ben, a C/C++ függvénypointerek modern megfelelői. Egy delegát segítségével egy olyan típusú változót definiálhatunk, amellyel metódusokra tudunk mutatni/hivatkozni. Nem akármilyenre, hanem - a C++ függvénypointerekkel analóg módon - olyanokra, amely típusa (paraméterlistája és visszatérési értéke) megfelel a delegát típusának. A delegát változó "meghívásával" az értékül adott (beregisztrált) metódus automatikusan meghívódik. A delegátok használatának egyik előnye az, hogy futási időben dönthetjük el, hogy több metódus közül éppen melyiket szeretnénk meghívni.

Néhány példa delegátok használatára:

  • egy univerzális sorrendező függvénynek paraméterként az elemek összehasonlítását végző függvény átadása,
  • egy általános gyűjteményen univerzális szűrési logika megvalósítása, melynek paraméterben egy delegát formájában adjuk át azt a függvényt, amely eldönti, hogy egy elemet bele kell-e venni a szűrt listába,
  • a publish-subscribe minta megvalósítása, amikor bizonyos objektumok más objektumokat értesítenek bizonyos magukkal kapcsolatos események bekövetkezéséről.

A következő példánkban lehetővé tesszük, hogy a korábban létrehozott Person osztály objektumai szabadon értesíthessék más osztályok objektumait arról, ha egy személy életkora megváltozott. Ennek érdekében bevezetünk egy delegát típust (AgeChangingDelegate), mely paraméterlistájában át tudja adni az emberünk életkorának aktuális, illetve új értékét. Ezt követően létrehozunk egy publikus AgeChangingDelegate típusú tagváltozót a Person osztályban, mely lehetővé teszi, hogy egy külső fél megadhassa azt a függvényt, amelyen keresztül az adott Person példány változásairól értesítést kér.

  1. Hozzunk létre egy új delegát típust, mely void visszatérési értékű, és két darab int paramétert elváró függvényre tud hivatkozni. Figyeljünk rá, hogy az új típust a Person osztály előtt, közvetlenül a névtér scope-jában definiáljuk!

    namespace PropertyDemo
    {
        public delegate void AgeChangingDelegate(int oldAge, int newAge);
    
        public class Person
        {
            // ...
    

    Az AgeChangingDelegate egy típus (figyeljük a VS színezését is), mely bárhol szerepelhet, ahol típus állhat (pl. lehet létrehozni ez alapján tagváltozót, lokális változót, függvény paramétert stb.).

  2. Tegyük lehetővé, hogy a Person objektumai rámutathassanak tetszőleges, a fenti szignatúrának megfelelő függvényre. Ehhez hozzunk létre egy AgeChangingDelegate típusú tagváltozót a Person osztályban!

    public class Person
    {
        public AgeChangingDelegate AgeChanging;
    

    Ez így most mennyire objektumorientált?

    A publikus tagváltozóként létrehozott metódusreferencia valójában (egyelőre) sérti az objektumorintált egységbezárási/információrejtési elveket. Erre később visszatérünk még.

  3. Hívjuk meg a függvényt minden alkalommal, amikor az emberünk kora megváltozik. Ehhez egészítsük ki az Age tulajdonság setterét a következőkkel.

    public int Age
    {
        get { return age; }
        set 
        {
            if (value < 0)
                throw new ArgumentException("Érvénytelen életkor!");
            if (AgeChanging != null)
                AgeChanging(age, value);
            age = value; 
        }
    }
    

    A fenti kódrészlet számos fontos szabályt demonstrál:

    • A validációs logika általában megelőzi az értesítési logikát.
    • Az értesítési logika jellegétől függ, hogy az értékadás előtt, vagy után futtatjuk le (ebben az esetben, mivel a "changing" szó egy folyamatban lévő dologra utal, az értesítés megelőzi az értékadást, a bekövetkezést múlt idő jelezni: "changed")
    • Fel kell készülnünk rá, hogy a delegate típusú tagváltozóhoz még senki nem rendelt értéket (nincs egy subscriber/előfizető sem). Ilyen esetekben a meghívásuk kivételt okozna, ezért meghívás előtt mindig ellenőrizni kell, hogy a tagváltozó értéke null-e.
    • Az esemény elsütésekor a null vizsgálatot és az esemény elsütést elegánsabb, tömörebb, és szálbiztosabb formában is meg tudjuk tenni a "?." null-conditional operátorral (C# 6-tól):
    if (AgeChanging != null)
        AgeChanging(age, value);
    

    helyett

    AgeChanging?.Invoke(age, value);
    

    Ez csak akkor süti el az eseményt, ha nem null, egyébként semmit nem csinál.

  4. Ha szigorúan nézzük, akkor csak akkor kellene elsütni az eseményt, ha a kor valóban változik is, vagyis a property set ágában meg kellene vizsgálni, az új érték egyezik-e a régivel. Megoldás lehet, ha a setter első sorában azonnal visszatérünk, ha az új érték egyezik a régivel:

    if (age == value) 
        return;
    
    
  5. Kész vagyunk a Person osztály kódjával. Térjünk át az előfizetőre! Ehhez mindenek előtt a Program osztályt kell kiegészítenünk egy újabb függvénnyel.

    class Program
    {
        // ...
    
        private static void PersonAgeChanging(int oldAge, int newAge)
        {
            Console.WriteLine(oldAge + " => " + newAge);
        }
    }
    

    Tipp

    Fokozottan ügyeljünk rá, hogy az új függvény a megfelelő scope-ba kerüljön! Míg a delegate típust az osztályon kívülre (de namespace-en belülre) helyeztük el, a függvényt az osztályon belülre helyezzük!

  6. Végezetül iratkozzunk fel a változáskövetésre a Main függvényben!

    static void Main(string[] args)
    {
      Person p = new Person();
      p.AgeChanging = new AgeChangingDelegate(PersonAgeChanging);
      // ...
    
  7. Futtassuk a programot!

    Pl. az AgeChanging?.Invoke(age, value); sorra töréspontot helyezve, az alkalmazást debuggolva futtatva, és a kódot léptetve figyeljük meg, hogy az esemény minden egyes setter futáskor, így az első értékadáskor és az inkrementálás során egyaránt lefut.

  8. Egészítsük ki a Main függvényt többszöri feliratkozással (a += operátorral lehet új feliratkozót felvenni a meglévők mellé), majd futtassuk a programot.

    p.AgeChanging = new AgeChangingDelegate(PersonAgeChanging);
    p.AgeChanging += new AgeChangingDelegate(PersonAgeChanging);
    p.AgeChanging += PersonAgeChanging; // Tömörebb szintaktika
    

    Láthatóan minden egyes értékváltozáskor mind a három beregisztrált/„feliratkozott” függvény lefut. Ez azért lehetséges, mert a delegate típusú tagváltozók valójában nem csupán egy függvényreferenciát, hanem egy függvényreferencia-listát tartalmaznak (és tartanak karban).

    Figyeljük meg a fenti harmadik sorban, hogy a függvényreferenciákat az először látottnál tömörebb szintaxissal is leírhatjuk: csak a függvény nevét adjuk meg a += operátor után, a new AgeChangingDelegate(...) nélkül. Ettől függetlenül ekkor is egy AgeChangingDelegate objektum fogja becsomagolni a PersonAgeChanging függvényeket a színfalak mögött. A gyakorlatban ezt a tömörebb szintaktikát szoktuk használni.

  9. Próbáljuk ki a leiratkozást is (szabadon választott ponton), majd futtassuk a programot.

    p.AgeChanging -= PersonAgeChanging;
    

3. Feladat – Esemény (event)

Ahogyan a tulajdonságok a getter és setter metódusoknak, addig a fent látott delegate mechanizmus a Java-ból ismert Event Listener-eknek kínálják egy a szintaktika tekintetében letisztultabb alternatíváját. A fenti megoldásunk azonban egyelőre még súlyosan sért pár OO elvet (egységbezárás, információrejtés). Ezt az alábbi két példával tudjuk demonstrálni.

  1. Az eseményt valójában kívülről (más osztályok műveleteiből) is ki tudjuk váltani. Ez szerencsétlen, hiszen így az esemény hamis módon akkor is kiváltható, ráadásul valótlan adatokkal, amikor az a gyakorlatban be sem következett, becsapva az összes előfizetőt. Ennek demonstrálására szúrjuk be a következő sort a Main függvény végére.

    p.AgeChanging(67, 12);
    

    Itt a p Person objektum vonatkozásában egy valótlan életkorváltozás eseményt váltottunk ki, becsapva minden előfizetőt. A jó megoldás az lenne, ha az eseményt csak a Person osztály műveletei tudnák kiváltani.

  2. Egy másik probléma a következő. Bár a += és a -= tekintettel vannak a listába feliratkozott többi függvényre, valójában az = operátorral bármikor felülírhatjuk (kitörölhetjük) mások feliratkozásait. Próbáljuk ki ezt is, a következő sor beszúrásával (közvetlenül a fel és leiratkozások után szúrjuk be).

    p.AgeChanging = null;
    
  3. Lássuk el az event kulcsszóval az AgeChanging tagváltozót Person.cs-ben!

    Person.cs
    public event AgeChangingDelegate AgeChanging;
    

    Az event kulcsszó feladata valójában az, hogy a fenti két problémát kizárva visszakényszerítse programunkat az objektumorientált mederbe.

  4. Próbáljuk meg lefordítani a programot. Látni fogjuk, hogy a fordító a korábbi kihágásainkat most már fordítási hibaként kezeli.

    event errors

  5. Távolítsuk el a három hibás kódsort (figyeljük meg, hogy már az első közvetlen értékadás is hibának minősül), majd fordítsuk le és futtassuk az alkalmazásunkat!

4. Feladat – Attribútumok

Sorosítás testreszabása attribútummal

Az attribútumok segítségével deklaratív módon metaadatokkal láthatjuk el forráskódunkat. Az attribútum is tulajdonképpen egy osztály, melyet hozzákötünk a program egy megadott eleméhez (típushoz, osztályhoz, interfészhez, metódushoz stb.). Ezeket a metainformációkat a program futása közben bárki (akár mi magunk is) kiolvashatja az úgynevezett reflection mechanizmus segítségével. Az attribútumok a Java annotációk .NET-beli megfelelőinek is tekinthetők.

property vs. attribútum vs. static

Felmerül a kérdés, hogy milyen osztályjellemzők kerüljenek tulajdonságokba és melyek attribútumokba egy osztály esetében. A tulajdonságok magára az objektum példányra vonatkoznak, míg az attribútum az azt leíró osztályra (vagy annak valamilyen tagjára).

Ilyen szempontból az attribútumok közelebb állnak a statikus tulajdonságokhoz, mégis megfontolandó, hogy egy adott adatot statikus tagként vagy attribútumként definiálnánk. Attribútummal sokkal deklaratívabb a leírás, és nem szennyezzük olyan részletekkel a kódot, melyeknek nem kellene az osztály publikus interfészén megjelennie.

A NET számos beépített attribútumot definiál, melyek funkciója a legkülönbözőbb féle lehet. A következő példában használt attribútumok például az XML sorosítóval közölnek különböző metainformációkat.

  1. Szúrjuk be a Main függvény végére a következő kódrészletet, majd futtassuk a programunkat!

    var serializer = new XmlSerializer(typeof(Person));
    var stream = new FileStream("person.txt", FileMode.Create);
    serializer.Serialize(stream, p);
    stream.Close();
    Process.Start(new ProcessStartInfo
    {
        FileName = "person.txt",
        UseShellExecute = true,
    });
    

    A fenti példából az utolsó Process.Start függvényhívás nem a sorosító logika része, csupán egy frappáns megoldás arra, hogy a Windows alapértelmezett szövegfájl nézegetőjével megnyissuk a keletkezett adatállományt. Ezt kipróbálhatjuk, de a használt .NET runtime-tól és az operációs rendszerünktől függ, támogatott-e. Ha nem, futás közben hibát kapunk. Ez esetben hagyjuk kikommentezve, és a person.txt fájlt a fájlrendszerben megkeresve kézzel nyissuk meg (a Visual Studio mappánkban a *\bin\Debug\* alatt található az .exe alkalmazásunk mellett).

  2. Nézzük meg a keletkezett fájl szerkezetét. Figyeljük meg, hogy minden tulajdonság a nevének megfelelő XML elemre lett leképezve.

  3. .NET attribútumok segítségével olyan metaadatokkal láthatjuk el a Person osztályunkat, melyek közvetlenül módosítják a sorosító viselkedését. Az XmlRoot attribútum lehetőséget kínál a gyökérelem átnevezésére. Helyezzük el a Person osztály fölé!

    [XmlRoot("Személy")]
    public class Person 
    {
        // ...
    }
    
  4. Az XmlAttribute attribútum jelzi a sorosító számára, hogy a jelölt tulajdonságot ne xml elemre, hanem xml attribútumra képezze le. Lássuk el ezzel az Age tulajdonságot (és ne a tagváltozót!)!

    [XmlAttribute("Kor")]
    public int Age
    
  5. Az XmlIgnore attribútum jelzi a sorosítónak, hogy a jelölt tulajdonság teljesen elhagyandó az eredményből. Próbáljuk ki a Name tulajdonság fölött.

    [XmlIgnore]
    public string Name { get; set; }
    
  6. Futtassuk az alkalmazásunkat! Hasonlítsuk össze az eredményt a korábbiakkal.

5. Feladat – Delegát 2.

A 2. és 3. feladatokban a delegátokkal esemény alapú üzenetküldést valósítottunk meg. A delegátok használatának másik tipikus esetében a függvényreferenciákat arra használjuk, hogy egy algoritmus vagy összetettebb művelet számára egy előre nem definiált lépés implementációját átadjuk.

A beépített generikus lista osztály (List<T>) FindAll függvénye például képes arra, hogy visszaadjon egy új listában minden olyan elemet, mely egy adott feltételnek eleget tesz. A konkrét szűrési feltételt egy függvény, pontosabban delegate formájában adhatjuk meg paraméterben (ez a FindAll minden elemre meghívja), mely igazat ad minden olyan elemre, amit az eredménylistában szeretnénk látni. A függvény paraméterének a típusa a következő előre definiált delegate típus (nem kell begépelni/létrehozni, hiszen már létezik):

public delegate bool Predicate<T>(T obj)

Note

A fenti teljes definíció megjelenítéséhez csak gépeljük be valahova, pl. a Main függvény végére a Predicate típusnevet, kattintsunk rajta egérrel, és az F12 billentyűvel navigáljunk el a definíciójához.

Vagyis bemenetként egy olyan típusú változót vár, mint a listaelemek típusa, kimenetként pedig egy logikai (bool) értéket. A fentiek demonstrálására kiegészítjük a korábbi programunkat egy szűréssel, mely a listából csak a páratlan elemeket fogja megtartani.

  1. Valósítsunk meg egy olyan szűrőfüggvényt az alkalmazásunkban, amely a páratlan számokat adja vissza:

    private static bool MyFilter(int n)
    {
        return n % 2 == 1;
    }
    
  2. Egészítsük ki a korábban írt kódunkat a szűrő függvényünk használatával:

    var list = new List<int>();
    list.Add(1);
    list.Add(2);
    list.Add(3);
    list = list.FindAll(MyFilter);
    
    foreach (int n in list)
    {
        Console.WriteLine($"Value: {n}");
    }
    
  3. Futtassuk az alkalmazást. Figyeljük meg, hogy a konzolon valóban csak a páratlan számok jelennek meg.

  4. Érdekességként elhelyezhetünk egy töréspontot (breakpoint) a MyFilter függvényünk belsejében, és megfigyelhetjük, hogy a függvény valóban minden egyes listaelemre külön-külön meghívódik.

Collection initializer szintaxis

Minden Add metódussal rendelkező, az IEnumerable interfészt implementáló osztályra (tipikusan kollekciók) a collection initializer szintaxis az alábbi módon:

var list = new List<int>() { 1, 2, 3 };

C# 12-től kezdve még egyszerűbb szintaxis (ún. collection expression) is használható egy gyűjtemény inicializálására, ha változó típusára a fordító ki tudja következtetni, hogy gyűjetményről van szó. Pl.:

List<int> list = [1, 2, 3];

6. Feladat – Lambda kifejezések

Az érintett témakörök az előadásanyagban részletesen szerepelnek, itt nem ismételjük meg őket Lásd „Előadás 02 - Nyelvi eszközök.pdf” dokumentum "Lambda expression (lambda kifejezés)" fejezete. A kulcselem a => (lambda operátor), mely segítségével lambda kifejezések, vagyis névtelen függvények definiálására van lehetőség.

Action és Func

A .NET beépített Func és Action generikus delegate típusokra itt idő hiányában nem térünk ki. Ettől még beletartoznak az alapanyagba!

Az előző, 5. feladatot oldjuk meg a következőképpen: ne adjunk meg külön szűrőfüggvényt, hanem a szűrési logikát egy lambda kifejezés formájában adjuk meg a FindAll műveletnek.

Ehhez mindössze egy sort kell megváltoztatni:

list = list.FindAll((int n) => { return n % 2 == 1; });

Egy név nélküli függvényt definiáltunk és adtunk át a FindAll műveletnek:

  • ez egy lambda kifejezés,
  • a => bal oldalán megadtuk a művelet paramétereket (itt csak egy volt),
  • a => jobb oldalán adtuk meg a művelet törzsét (ugyanaz, mint a korábbi MyFilter törzse).

A fenti sort jóval egyszerűbb és áttekinthetőbb formába is írhatjuk:

list = list.FindAll(n => n % 2 == 1);

A következő egyszerűsítéseket eszközöltük:

  • a paraméter típusát nem írtuk ki: a fordító ki tudja következtetni a FindAll delegate paraméteraméterének típusából, mely a korábban vizsgált Predicate.
  • a paraméter körüli zárójelet elhagyhattuk (mert csak egy paraméter van)
  • a => jobb oldalán elhagyhattuk a {} zárójeleket és a return-t (mert egyetlen kifejezésből állt a függvény törzse, mellyel a függvény visszatér).

7. További nyelvi konstrukciók

Az alábbiakban kitekintünk néhány olyan C# nyelvi elemre, melyek a napi programozási feladatok során egyre gyakrabban használatosak. A gyakorlat során jó eséllyel már nem marad idő ezek áttekintésére.

Kifejezéstörzsű tagok (Expression-bodied members)

Időnként olyan rövid függvényeket, illetve tulajdonságok esetén kifejezetten gyakran olyan rövid get/set/init definíciókat írunk, melyek egyetlen kifejezésből állnak. Ez esetben a függvény, illetve tulajdonság esetén a get/set/init törzse megadható ún. kifejezéstörzsű tagok (expression-bodied members) szintaktikával is, a => alkalmazásával. Ez akkor is megtehető, ha az adott kontextusban van visszatérési érték (return utasítás), akár nincs.

A példákban látni fogjuk, hogy a kifejezéstestű tagok alkalmazása nem több, mint egy kisebb szintaktikai "csavar" annak érdekében, hogy ilyen egyszerű esetekben minél kevesebb körítő kódot kelljen írni.

Nézzünk először egy függvény példát (feltesszük, hogy az osztályban van egy Age tagváltozó vagy tulajdonság):

public int GetAgeInDogYear() => Age * 7; 
public void DisplayName() => Console.WriteLine(ToString());
Mint látható, elhagytuk a {} zárójeleket és a return utasítást, így tömörebb a szintaktika.

Fontos

Bár itt is a => tokent használjuk, ennek semmi köze nincs a korábban tárgyalt lambda kifejezésekhez: egyszerűen csak arról van szó, hogy ugyanazt a => tokent (szimbólumpárt) két teljesen eltérő dologra használja a C# nyelv.

Példa tulajdonság getter megadására:

public int AgeInDogYear { get => Age * 7; }

Sőt, ha csak getterje van a tulajdonságnak, a get kulcsszót és a kapcsos zárójeleket is lehagyhatjuk.

public int AgeInDogYear => Age * 7;

Ezt az különbözteti meg a korábban látott függvények hasonló szintaktikájától, hogy itt nem írtuk ki a kerek zárójeleket.

Note

A Microsoft hivatalos dokumentációjának magyar fordításában az "expression-bodied members" nem "kifejezéstörzsű", hanem "kifejezéstestű" tagként szerepel. Köszönjük szépen, de a függvényeknek sokkal inkább törzse, mint teste van a magyar terminológiában, így ezt nem vesszük át...

Objektuminicializáló (Object initializer)

A publikus tulajdonságok/tagváltozók inicializálása és a konstruktorhívás kombinálható egy úgynevezett objektuminicializáló (object initializer) szintaxis segítségével. Ennek alkalmazása során a konstruktorhívás után kapcsos zárójelekkel blokkot nyitunk, ahol a publikus tulajdonságok/tagváltozók értéke adható meg, az alábbi szintaktikával.

var p = new Person()
{
    Age = 17,
    Name = "Luke",
};

Az tulajdonságok/tagok inicializálása a konstruktor lefutása után történik (amennyiben tartozik az osztályhoz konstruktor). Ez a szintaktika azért is előnyös, mert egy kifejezésnek számít (azon hárommal szemben, mintha létrehoznánk egy inicializálatlan, Person objektumot, és két további lépésben adnánk értéket az Age és Name tagoknak). Így akár közvetlenül függvényhívás paramétereként átadható egy inicializált objektum, anélkül, hogy külön változót kellene deklarálni.

void Foo(Person p)
{
    // do something with p
}
Foo(new Person() { Age = 17, Name = "Luke" });

A szintaxis ráadásul copy-paste barát, mert ahogy a fenti példákban is látszik, hogy nem számít, hogy az utolsó tulajdonság megadása után van-e vessző, vagy nincs.

Tulajdonságok - Init only setter

Az előző pontban lévő objektuminicializáló szintaxis nagyon kényelmes, viszont azt követeli meg a tulajdonságtól, hogy publikus legyen. Ha azt akarjuk, hogy egy tulajdonság értéke csak az objektum létrehozásakor legyen megadható, ahhoz konstruktor paramétert kell bevezessünk, és egy csak olvasható (csak getterrel rendelkező) tulajdonságnak kell azt értékül adjuk. Erre a problémára ad egyszerűbb megoldást az ún. Init only setter szintaxis, ahol olyan "settert" tudunk készíteni az init kulcsszóval, mely állítása csak a konstruktorban és az előző fejezetben ismertetett objektuminicializáló szintaxis alkalmazása során engedélyezett, ezt követően már nem.

public string Name { get; init; }
var p = new Person()
{
    Age = 17,
    Name = "Luke",
};

p.Name = "Test"; // build hiba, utólag nem megváltoztatható

Továbbá lehetőségünk van az init only setter kötelezőségét is beállítani a tulajdonságon alkalmazott required kulcsszóval. Ekkor a tulajdonság értékét mindenképpen meg kell adni az objektuminicializáló szintaxisban, különben fordítási hibát kapunk.

public required string Name { get; init; }

Ez azért is hasznos, mert ha egyébként is szeretnénk tulajdonságokat publikálni az osztályból, és egyébként is szeretnénk támogatni az objektum inicializáló szintaxist, akkor így meg tudjuk spórolni a kötelező konstruktor paramétereket.

8. Feladat – Generikus osztályok

Megjegyzés: erre a feladatra jó eséllyel nem marad idő. Ez esetben célszerű a feladatot gyakorlásképpen otthon elvégezni.

A .NET generikus osztályai hasonlítanak C++ nyelv template osztályaihoz, de közelebb állnak a Java-ban már megismert generikus osztályokhoz. A segítségükkel általános (több típusra is működő), de ugyanakkor típusbiztos osztályokat hozhatunk létre. Generikus osztályok nélkül, ha általánosan szeretnénk kezelni egy problémát, akkor object típusú adatokat használunk (mert .NET-ben minden osztály az object osztályból származik). Ez a helyzet például az ArrayList-tel is, ami egy általános célú gyűjtemény, tetszőleges, object típusú elemek tárolására alkalmas. Lássunk egy példát az ArrayList használatára:

var list = new ArrayList();
list.Add(1);
list.Add(2);
list.Add(3);
for (int n = 0; n < list.Count; n++)
{
    // Castolni kell, különben nem fordul
    int i = (int)list[n];
    Console.WriteLine($"Value: {i}");
}

A fenti megoldással a következő problémák adódnak:

  • Az ArrayList minden egyes elemet object-ként tárol.
  • Amikor hozzá szeretnénk férni a lista egy eleméhez, mindig a megfelelő típusúvá kell cast-olni.
  • Nem típusbiztos. A fenti példában semmi nem akadályoz meg abban (és semmilyen hibaüzenet sem jelzi), hogy az int típusú adatok mellé beszúrjunk a listába egy másik típusú objektumot. Ilyenkor csak a lista bejárása során kapnánk hibát, amikor a nem int típust int típusúra próbálunk castolni. Generikus gyűjtemények használatakor az ilyen hibák már a fordítás során kiderülnek.
  • Érték típusú adatok tárolásakor a lista lassabban működik, mert az érték típust először be kell dobozolni (boxing), hogy az object-ként (azaz referencia típusként) tárolható legyen.

A fenti probléma megoldása egy generikus lista használatával a következőképpen néz ki (a gyakorlat során csak a kiemelt sort módosítsuk a korábban begépelt példában):

var list = new List<int>();
list.Add(1);
list.Add(2);
list.Add(3);
for (int n = 0; n < list.Count; n++)
{
    int i = list[n]; // Nem kell cast-olni
    Console.WriteLine($"Value: {i}");
}

2024-03-29 Szerzők