Oktatóanyag: Az összetett adatszerkezetek közötti választás: tuple-ök, rekordok, strukturák és osztályok

Tip

Ez a cikk az Alapismeretek szakasz része, amely olyan fejlesztők számára készült, akik legalább egy programozási nyelvet ismernek, és C#-t tanulnak. Ha még csak most ismerkedik a programozással, kezdje az első lépésekkel. A gyorshivatkozási táblázatot a Típus kiválasztása című témakörben találhatja meg.

A C#-alkalmazások egyik első tervezési döntése a létrehozni kívánt típus kiválasztása. Legyen a menüelem class vagy record? Egy gyors számításnak tuple-t vagy elnevezett típust kellene visszaadnia? Minden választási lehetőség meghatározza, hogyan kezeli a kód az egyenlőséget, a megváltoztathatóságot és a polimorfizmust. A rossz választás sablonhoz, hibákhoz vagy mindkettőhöz vezet.

Ebben az oktatóanyagban létrehoz egy kis kávézómodellt, amely menüelemeket, rendeléseket, érzékelőolvasásokat és kedvezményszabályzatokat használ. Elemezheti a jellemzőket, és meghatározhatja az egyes fogalmakhoz tartozó legjobb C#-típust. Az út során megtanulhatja felismerni azokat a tervezési nyomásokat, amelyek az egyik típus felé mutatnak a másik felett.

Ebben az útmutatóban Ön:

  • Felismerés arról, hogy egy tuple helyes választás-e több érték visszaadására.
  • Nem módosítható adatokat modellezhet rekordosztályokkal, és megértheti az értékalapú egyenlőséget.
  • Kis méretű, másolható adatokat jelöl egy rekordstruktúra segítségével.
  • Kezelje az elváltozó állapotot és viselkedést egy osztállyal.
  • Egy osztályt kiterjeszthetünk örökléssel, hogy szabályokat adjunk hozzá vagy szigorítsunk meg.
  • Közös képességek definiálása a nem kapcsolódó típusok között egy felülettel.

Prerequisites

Ideiglenes csoportosításhoz használjon tuple-t

A kávézónak olyan módszerre van szüksége, amely a rendelések teljes számát és a napi bevételt is visszaadja. Ehhez osztályt vagy szerkezetet is definiálhat, de az egyik metódus két értéke nem mindig indokolja az új típust.

(int TotalOrders, decimal Revenue) GetDailySummary(int orders, decimal revenue)
    => (orders, revenue);

Console.WriteLine("=== Tuple: daily summary ===");
var summary = GetDailySummary(42, 1234.50m);
Console.WriteLine($"Orders: {summary.TotalOrders}, Revenue: {summary.Revenue:F2}");

var (orders, revenue) = summary;
Console.WriteLine($"Deconstructed: {orders} orders, {revenue:F2}");

egy tömböt ad vissza. A hívó az egyes elemeket név alapján éri el, vagy mindkettőt helyi változókká bontja. Nincs szükség osztály- vagy szerkezetdefinícióra.

Miért működik a legjobban egy tuple ebben a példában?

A tömb azért működik itt, mert a csoportosítás helyi: egy függvény hozza létre, egy hívó pedig felhasználja. Az elnevezett elemek teljes típusú ceremónia nélkül teszik egyértelművé a szándékot. Ha azt tapasztalja, hogy ugyanazt a rekordalakzatot több metóduson is átengedi, az azt jelzi, hogy előlépteti azt egy rekordra vagy osztályra. Ezt az evolúciót az oktatóanyag későbbi részében láthatja. A tuple szintaxisáról és képességeiről további információt a Tuple-típusok című témakörben talál.

Nem módosítható adatok rekordjának használata

Minden kávézónak szüksége van egy menüre. A menüelemek neve, ára és táplálkozási megjegyzése van. Ezek az értékek nem változnak az elem listázása után. A két rendszer, amelyek egyaránt hivatkoznak a "Latte $4.50-ért", meg kell egyezniük abban, hogy ugyanarról a dologról beszélnek, még akkor is, ha különböző objektumokat hoztak létre.

Pozíciórekord deklarálása:

record class MenuItem(string Name, decimal Price, string NutritionalNote);

A fordító létrehoz egy konstruktort, destruktort, Equals, GetHashCode és ToString abból az egyetlen sorból. Helyezze a rekordot a működéshez:

Console.WriteLine("\n=== Record class: MenuItem ===");
var latte = new MenuItem("Latte", 4.50m, "Contains dairy");
var latte2 = new MenuItem("Latte", 4.50m, "Contains dairy");
var seasonal = latte with { Name = "Pumpkin Spice Latte", Price = 5.25m };

Console.WriteLine(latte);
Console.WriteLine(seasonal);
Console.WriteLine($"Same reference (latte vs latte2): {ReferenceEquals(latte, latte2)}");
Console.WriteLine($"Value equal (latte vs latte2):    {latte == latte2}");
Console.WriteLine($"Value equal (latte vs seasonal):  {latte == seasonal}");

Két MenuItem azonos adattal rendelkező példány egyenlő annak ellenére, hogy külön objektumok. Ez a viselkedés az értékalapú egyenlőséget szemlélteti. A with kifejezés szezonális változatot hoz létre az eredeti mutálása nélkül.

A rekordosztály akkor megfelelő, ha az identitás adatokból származik, nem objektumhivatkozásból, és a példányok ritkán változnak a létrehozás után. Olvasható ToString() kimenetet, szerkezeti egyenlőséget és with támogatást kap a dobozból. Részletesebb útmutatóért tekintse meg a Rekordok és a Rekordok oktatóanyagot.

Rekordstruktúra használata kis értéktípusokhoz

A kávéfőző beépített hőmérővel rendelkezik, amely hőmérsékleti értékeket jelez. Minden olvasás apró – szám és egység – és naplókba, riasztásokba és irányítópultokba másolódik. Nem szeretné, hogy az egyik példány módosítása áthatoljon a többien.

Rekordstruktúra deklarálása:

record struct Measurement(double Value, string Unit);

Használja a rekordstruktúrát:

Console.WriteLine("\n=== Record struct: Measurement ===");
var temp = new Measurement(72.5, "°F");
var copy = temp;

copy = copy with { Value = 23.0, Unit = "°C" };

Console.WriteLine($"Original: {temp.Value}{temp.Unit}");
Console.WriteLine($"Copy (converted): {copy.Value}{copy.Unit}");

Az temp hozzárendelése az copy-hez független értéket hoz létre. A with kifejezés új értéket hoz létre anélkül, hogy hozzányúl az eredetihez – ugyanaz a minta, mint egy rekordosztály, de másolás közbeni hozzárendelési viselkedéssel, nem pedig másolással.

A rekordstruktúra akkor illik, ha az adatok kicsik (néhány primitív mező), és a másolás olcsóbb, mint a halomfoglalás. Értékegyenlőséget és with támogatást kap, mint egy rekordosztály, alatta valódi érték szemantikával. A mérések, koordináták és hasonló könnyen kezelhető adatok természetes jelöltek. További információt a Rekordok és a Struktúratípusok című témakörben talál.

Használjon osztályt, ha változékony állapotra és viselkedésre van szüksége

Amikor egy ügyfél felmegy a számlálóhoz, a barista elindít egy rendelést, és egyenként hozzáad elemeket. Az összeg növekszik, az állapot "Függőben" állapotról "Kész" értékre változik, és az egyidejűleg leadott két rendelés – még azonos tételek esetén is – továbbra is eltérő megrendelések.

class Order : IOrder
{
    public virtual string Status { get; set; } = "Pending";
    private readonly List<(string Name, decimal Price)> _items = [];

    public void AddItem(string name, decimal price) => _items.Add((name, price));

    public decimal Total => _items.Sum(i => i.Price);

    public override string ToString() =>
        $"Order [{Status}]: {string.Join(", ", _items.Select(i => i.Name))} - Total: {Total:F2}";
}
Console.WriteLine("\n=== Class: Order ===");
var order = new Order();
order.AddItem("Latte", 4.50m);
order.AddItem("Croissant", 3.25m);
order.Status = "Ready";

Console.WriteLine(order);

Az Order osztály nyomon követi az elemeket, kiszámítja a folyamatosan frissülő összeget, és lehetővé tesz egy beállítható Status. Itt az osztály a megfelelő eszköz, mert az objektum változó állapotot hordoz, amely az élettartama során változik, a viselkedés (metódusok) a típus céljának középpontjában állnak, és az identitás számít – az azonos elemekkel rendelkező két rendelés még mindig eltérő sorrend. További információ: Osztályok, szerkezetek és rekordok.

Öröklés használata, ha ki kell terjesztenie egy osztályt

A kávézó vendéglátási eseményeket indít. A vendéglátási rendelés továbbra is rendelés – tételekkel és összegekkel is rendelkezik –, de nyomon követi a vendégszámot is, és a konyha előtt vezetői jóváhagyást igényel. Ahelyett, hogy duplikálja Ordera logikát, egy speciális osztályt kell létrehoznia.

class CateringOrder : Order
{
    public int MinimumGuests { get; }
    public string? ApprovedBy { get; private set; }

    public CateringOrder(int minimumGuests) => MinimumGuests = minimumGuests;

    public void Approve(string manager) => ApprovedBy = manager;

    public override string Status
    {
        get => base.Status;
        set
        {
            if (value == "Ready" && ApprovedBy is null)
                throw new InvalidOperationException(
                    "A catering order requires manager approval before it can be marked ready.");
            base.Status = value;
        }
    }

    public override string ToString() =>
        $"Catering [{Status}] for {MinimumGuests}+ guests, approved by: {ApprovedBy ?? "(none)"} - Total: {Total:F2}";
}

CateringOrder, AddItem és Total újrahasznosítja az alaposztályból. A Status felülbírálás meghúzza a szerződést – az előzetes jóváhagyás nélküli hívás Status = "Ready" kivételt eredményez:

Console.WriteLine("\n=== Inheritance: CateringOrder ===");
var catering = new CateringOrder(minimumGuests: 20);
catering.AddItem("Coffee (serves 20)", 45.00m);
catering.AddItem("Pastry platter", 60.00m);

try
{
    catering.Status = "Ready";
}
catch (InvalidOperationException ex)
{
    Console.WriteLine($"Blocked: {ex.Message}");
}

catering.Approve("Sam");
catering.Status = "Ready";
Console.WriteLine(catering);

Ez az egyetlen származtatott osztály három öröklési fogalmat szemléltet:

  • Hozzáadott állapot: MinimumGuests és ApprovedBy csak a származtatott osztályban létezik.
  • Hozzáadott viselkedés: Approve új – a bázis Order nem tud a jóváhagyásokról.
  • Felülbírált viselkedés: a Status beállító olyan üzleti szabályt kényszerít ki, amellyel az alaposztály nem rendelkezik.

Az öröklés akkor illik, ha az új típus az alaptípus speciális verziója, és a szabályok hozzáadása vagy szigorítása során újra fel kell használnia a meglévő állapotot és viselkedést. A megosztott alaposztály természetesebb, mint egy felület, amikor a típusok implementálást osztanak meg, nem csak egy szerződést.

Megosztott képességek definiálása felülettel

A kávézó különböző promóciókat futtat – boldog órát, hűségjutalmakat, szezonális akciókat. A fizetési folyamatnak a mai napon érvényes kedvezményt kell alkalmaznia, anélkül hogy ismerné az egyes szabályzatok részleteit. Meg kell találnia egy módot arra, hogy elmondhassa: "bármi, ami kedvezményt alkalmazhat," anélkül, hogy a fizetési folyamatot egyetlen osztályhoz kötné.

interface IDiscountPolicy
{
    decimal Apply(decimal total);
}

class HappyHourDiscount : IDiscountPolicy
{
    public decimal Apply(decimal total) => total * 0.80m;
}

class LoyaltyDiscount : IDiscountPolicy
{
    public decimal Apply(decimal total) => total - 1.00m;
}

A Checkout metódus bármilyen IDiscountPolicy elfogad, így új szabályzatokat vezethet be anélkül, hogy módosítaná a pénztári logikát.

static decimal Checkout(decimal total, IDiscountPolicy policy) => policy.Apply(total);

Console.WriteLine("\n=== Interface: discount policy ===");
decimal subtotal = 12.00m;
Console.WriteLine($"Happy hour (20% off): {Checkout(subtotal, new HappyHourDiscount()):F2}");
Console.WriteLine($"Loyalty ($1 off):     {Checkout(subtotal, new LoyaltyDiscount()):F2}");

Az interfész deklarál egy szerződést – egy olyan tagcsoportot, amelyet minden implementálási típusnak meg kell adnia. A felület azért működik itt, mert a kedvezménytípusok nem kapcsolódnak egymáshoz (nem osztanak meg alaposztályt), a pénztárnak azonban egységesen kell kezelnie őket. Az interfészek megkönnyítik a tesztelést is: beilleszthet egy helyettesítő szabályzatot az éles kód érintése nélkül. További részletekért tekintse meg az Interfészek című témakört.

A típusválasztás fejlesztése

Ezek közül a döntések közül egyik sem végleges. Valójában könnyen módosíthatja őket, mielőtt kiad egy könyvtárat, ahol a jelentős változások válhatnak fontossá. Ahogy a követelmények növekednek, átalakíthat egy egyszerű típust egy gazdagabbra. Íme három gyakori evolúció.

Rekord →: a csoportosítás folyamatosan megjelenik

A GetDailySummary tuple egy metóduson belül jól működik, de amikor jelentésekben, irányítópultokban és tesztekben kezdi használni, egy névvel ellátott típus sokat segít. A tuple rekorddá előléptetése és számított tulajdonságok hozzáadása:

record class DailySummary(int TotalOrders, decimal Revenue)
{
    public decimal AverageTicket => TotalOrders > 0 ? Revenue / TotalOrders : 0m;
}

Azok a hívók, amelyek korábban bontották a tupelt, mostantól ingyen kapnak ToString(), értékegyenlőséget, és egy természetes helyet a származtatott adatokhoz, mint például AverageTicket.

Console.WriteLine("\n=== Evolve: tuple -> record ===");
var daily = new DailySummary(120, 525.75m);
Console.WriteLine(daily);
Console.WriteLine($"Average ticket: {daily.AverageTicket:F2}");

Struct → osztály: szüksége van öröklésre

Az üzlet karbantartási csapata kalibrált értékeket kér: egy eltolással korrigált érzékelőértéket. A Measurement rekordstruktúra nagyszerűen használható nyers adatokhoz, de a szerkezetek nem támogatják az öröklést, így nem lehet kalibrált változatot származtatni. Előléptetés osztályhierarchiába:

class SensorReading(double value, string unit)
{
    public double Value { get; } = value;
    public string Unit { get; } = unit;

    public virtual string Display() => $"{Value}{Unit}";
}

class CalibratedReading(double value, string unit, double offset)
    : SensorReading(value, unit)
{
    public double Offset { get; } = offset;

    public override string Display() => $"{Value + Offset}{Unit} (offset {Offset:+0.0;-0.0})";
}

CalibratedReading örököl SensorReading-től, majd felülbírálja Display()-t, hogy tartalmazza az eltolást. Ez a mintázat nem valósítható meg struktúra vagy rekordstruktúra segítségével.

Console.WriteLine("\n=== Evolve: struct -> class ===");
var raw = new SensorReading(72.5, "°F");
var calibrated = new CalibratedReading(72.5, "°F", offset: -0.3);

Console.WriteLine($"Raw:        {raw.Display()}");
Console.WriteLine($"Calibrated: {calibrated.Display()}");

Osztály → osztály + interfész: több típushoz is polimorfizmusra van szükség

Az Order osztály önmagában is jól működik, de ha CateringOrder létezik, akkor a pénztár, jelentéskészítés és nyomtatás képes legyen bármilyen megrendelést kezelni anélkül, hogy foglalkozna azzal, milyen konkrét típus. Nyerje ki a felületet azokkal a tagokkal, amelyektől a hívók ténylegesen függenek:

interface IOrder
{
    string Status { get; set; }
    decimal Total { get; }
}

Mindkettő Order , és CateringOrder már megfelel ennek a szerződésnek. Most egyetlen metódus kezeli a következő típusokat:

Console.WriteLine("\n=== Evolve: class -> class + interface ===");
static void PrintOrderSummary(IOrder o) =>
    Console.WriteLine($"  {o.Total:F2} [{o.Status}]");

var walkIn = new Order();
walkIn.AddItem("Mocha", 5.00m);
walkIn.Status = "Ready";

var banquet = new CateringOrder(minimumGuests: 50);
banquet.AddItem("Coffee service", 90.00m);
banquet.Approve("Alex");
banquet.Status = "Ready";

Console.WriteLine("All orders:");
foreach (IOrder o in new IOrder[] { walkIn, banquet })
    PrintOrderSummary(o);

A felület kinyerése nem változtat Order-n vagy CateringOrder-n — csak egyértelművé teszi a megosztott alakzatot, ami a tesztelést is megkönnyíti.

Gyors döntési útmutató

Használja ezt a táblázatot kiindulási pontként, ha nem biztos abban, hogy melyik típust válassza:

Kérdés Optimális illesztés
Egy metódusból ad vissza néhány értéket? Tuple
Nem módosítható adatok, ahol az egyenlőség értékek szerint történik? Rekordosztály
Kicsi, másolható értékadatok egyenlőséggel? Rekordstruktúra
Változtatható állapot, viselkedés vagy referenciaidentitás? Class
Meglévő osztály speciális verziója? Származtatott osztály
Megosztott képesség a nem kapcsolódó típusok között? Interface

Ha ezek közül egyik sem illeszkedik jól, fontolja meg a típusok kombinálását. Egy osztály például implementálhat egy felületet, a rekord pedig egy szerkezet. A teljes összehasonlításért tekintse meg a Típus kiválasztása című témakört.