🔥 Darmowa Roadmapa .NET — kompletny plan nauki do pierwszej pracy. Pobierz i dołącz do Listy VIP →

Rekordy, LINQ i async – quiz: zgadnij output 9 fragmentów

Quiz z kodem C# — rekordy, LINQ i async, zgadnij output dziewięciu fragmentów

Ten catch łapie dokładnie ten wyjątek, który metoda rzuca. Typ się zgadza, blok
stoi na miejscu i nie łapie. Aplikacja kończy się nieobsłużonym wyjątkiem, a w
logach nie ma o nim ani jednego wpisu.

To zagadka numer siedem z dziewięciu, które znajdziesz niżej i chyba najbardziej
kosztowna, bo dotyczy linijki, którą programiści piszą codziennie. Post towarzyszy
drugiemu odcinkowi serii Zgadnij Output: dziewięć fragmentów kodu C#, dziewięć
pytań „co wypisze konsola”, i przy każdym pełne wyjaśnienie mechanizmu, nie tylko
gołą odpowiedź.

Tym razem wszystko pochodzi z nowoczesnego C# – rekordy, LINQ, async – czyli z kodu, który piszesz w pracy, a nie z zagadek wymyślonych po to, żeby zagiąć. Format działa najlepiej, gdy naprawdę spróbujesz zgadnąć PRZED przewinięciem do wyjaśnienia.

Każdy fragment to kompilowalny C# (.NET 6+, top-level statements) i każdy został
uruchomiony na .NET 10 przed publikacją
 – wyniki poniżej to realny output konsoli,
nie rozumowanie „powinno wyjść”. Kod jest 1:1 z odcinkiem na YouTube – zero uproszczeń między wideo a tekstem.

Zagadka #1: rekord kontra klasa

var p1 = new Punkt(1, 2);
var p2 = new Punkt(1, 2);
Console.WriteLine(p1 == p2);

var q1 = new Pozycja { X = 1, Y = 2 };
var q2 = new Pozycja { X = 1, Y = 2 };
Console.WriteLine(q1 == q2);

record Punkt(int X, int Y);

class Pozycja
{
    public int X { get; init; }
    public int Y { get; init; }
}

Odpowiedź: True, potem False.

Rekord to wciąż typ referencyjny i oba obiekty leżą na stercie, to nie jest różnica
„stos kontra sterta”, jak podpowiada popularna intuicja. Różnica siedzi w tym, co
wygenerował kompilator.

Dla rekordu wygenerował równość strukturalną: własne Equals, GetHashCode oraz i to jest tu kluczowe przeciążone operatory == i !=, porównujące po kolei
wszystkie pola. Dwa osobne obiekty o tej samej zawartości są więc równe. Zwykła klasa nie dostaje żadnego z tych elementów, więc == porównuje referencje, a dwa new to dwa różne obiekty.

Zasada:
== w C# nie ma jednego znaczenia. Zawsze sprawdź, czy typ, na którym
go piszesz, ma własne przeciążenie — record i string mają, zwykła class nie.

Zagadka #2: with i płytka kopia

var oryginal = new Koszyk("Ala", new List<string> { "mysz" });
var kopia = oryginal with { Klient = "Bartek" };

kopia.Produkty.Add("klawiatura");

Console.WriteLine(oryginal.Produkty.Count);
Console.WriteLine(ReferenceEquals(oryginal.Produkty, kopia.Produkty));

record Koszyk(string Klient, List<string> Produkty);

Odpowiedź: 2, potem True.

with tworzy nowy obiekt przez wygenerowany konstruktor kopiujący, który przepisuje pola jeden do jednego. Dla Klient przepisał wartość podmienioną w with, ale dla Produkty przepisał referencję do tej samej listy.

To jest kopia płytka: nowy jest wyłącznie sam rekord, nie obiekty, które ten rekord
trzyma. Obie zmienne patrzą na tę samą listę, więc dopisanie przez kopia widzi też
oryginal, a ReferenceEquals na obu listach zwraca True.

Zasada: 
rekord z mutowalną kolekcją w środku nie jest niemutowalny – on tylko
wygląda na niemutowalny. Jeśli zależy Ci na izolacji, kopiuj kolekcję jawnie:
oryginal with { Produkty = new List<string>(oryginal.Produkty) }.

Zgadnij, zanim klikniesz

Kopia rekordu, jedna lista

var oryginal = new Koszyk("Ala", new List<string> { "mysz" }); var kopia = oryginal with { Klient = "Bartek" }; kopia.Produkty.Add("klawiatura"); Console.WriteLine(oryginal.Produkty.Count); Console.WriteLine(ReferenceEquals(oryginal.Produkty, kopia.Produkty)); record Koszyk(string Klient, List<string> Produkty);

Klawiaturę dopisujemy do kopii. Co wypisze pierwsza linia — ile elementów ma lista w oryginale?

Prawie — poprawna odpowiedź to 2. with tworzy nowy rekord przez konstruktor kopiujący, ale przepisuje pola jeden do jednego — dla listy oznacza to przepisanie samej referencji. Obie zmienne patrzą więc na tę samą listę, a druga linia (ReferenceEquals) wypisuje True. Szczegóły w sekcji „Zagadka #2″ wyżej.
Zgadłeś! To 2. with robi kopię płytką: nowy jest sam rekord, nie obiekty, które trzyma. Lista pozostaje wspólna, więc dopisanie przez kopia widzi też oryginal — a ReferenceEquals na obu listach zwraca True.

Zagadka #3: new kontra override

Bazowy obiekt = new Pochodny();
Console.WriteLine(obiekt.Nazwa());
Console.WriteLine(new Pochodny().Nazwa());

class Bazowy
{
    public virtual string Nazwa() => "Bazowy";
}

class Pochodny : Bazowy
{
    public new string Nazwa() => "Pochodny";
}

Odpowiedź: Bazowy, potem Pochodny.

override podmienia wpis w tablicy metod wirtualnych – wywołanie idzie wtedy po
typie obiektu, i to jest polimorfizm. new nie podmienia niczego, tylko ukrywa
metodę bazową: powstaje druga, niezależna metoda o tej samej nazwie, a kompilator
wybiera między nimi po typie zmiennej, na której piszesz wywołanie.

Pierwsza linia ma zmienną typu Bazowy, więc wywołuje metodę wirtualną z Bazowy a Pochodny jej nie nadpisał, tylko schował. Stąd Bazowy, mimo że w pamięci leży obiekt klasy Pochodny. Druga linia operuje na wyrażeniu typu Pochodny, więc widoczna jest metoda ukrywająca.

Gdybyś usunął słowo new, kod nadal by się skompilował i zadziałał identycznie dostałbyś tylko ostrzeżenie CS0108: „hides inherited member. Use the new keyword
if hiding was intended”.

Zasada: 
to jedyny przypadek w C#, gdy ten sam obiekt odpowiada różnie zależnie
od typu zmiennej, w której go trzymasz. Jeśli piszesz new nieświadomie, w hierarchii klas masz cichą bombę.

Zagadka #4: ile kropek wypisze LINQ

var liczby = new List<int> { 1, 2, 3 };
var zapytanie = liczby.Where(x =>
{
    Console.Write(".");
    return x > 1;
});

Console.WriteLine(zapytanie.Count());
Console.WriteLine(zapytanie.Count());

Odpowiedź: ...2, potem znowu ...2 — czyli sześć kropek.

zapytanie nie jest wynikiem. To przepis: obiekt, który przy każdej enumeracji od
nowa
 przechodzi po źródle i od nowa woła lambdę. Kropka drukuje się raz na element, więc trzy razy na jedno przejście, a Count() wymusza pełne przejście. Dwa wywołania Count() to dwa przejścia.

W tym przykładzie kosztem jest kropka na ekranie. W realnym kodzie tym kosztem bywa zapytanie do bazy, wywołanie HTTP albo odczyt pliku — wykonane tyle razy, ile razy ktoś dotknie zmiennej typu IEnumerable<T>. A dotyka jej każde Count(), każde Any(), każdy foreach.

Zasada: 
jeśli zamierzasz użyć wyniku zapytania więcej niż raz, zmaterializuj go raz – .ToList(). Ostrzeżenie analizatora „possible multiple enumeration” jest
dokładnie o tym i prawie nigdy nie jest fałszywym alarmem.

Zagadka #5: yield return i finally

foreach (var x in Licz())
{
    Console.WriteLine(x);
    if (x == 1) break;
}
Console.WriteLine("po foreach");

IEnumerable<int> Licz()
{
    try
    {
        Console.WriteLine("start");
        yield return 1;
        yield return 2;
        Console.WriteLine("koniec petli");
    }
    finally
    {
        Console.WriteLine("finally");
    }
}

Odpowiedź: start, 1, finally, po foreach.

Dzieją się tu dwie rzeczy naraz. Pierwsza: metoda z yield return nie wykonuje się
w chwili wywołania – kompilator zamienia ją na maszynę stanów, która robi kolejny
kawałek pracy dopiero przy pobraniu kolejnego elementu. Dlatego start pojawia się
po wejściu w foreach, a nie przy Licz().

Druga, ciekawsza: break przerywa pętlę, ale foreach ma pod spodem własne
try/finally i przy każdym wyjściu woła Dispose() na enumeratorze. To właśnie
Dispose() wykonuje blok finally wewnątrz iteratora – stąd finally na ekranie,
mimo że maszyna stanów nigdy nie doszła do yield return 2 ani do linii
"koniec petli".

Zasada: 
finally w iteratorze to jedyne miejsce, w którym bezpiecznie zamkniesz
plik albo połączenie — wykona się nawet wtedy, gdy ktoś weźmie z Twojego strumienia jeden element i wyjdzie. Ale uwaga: przy ręcznym GetEnumerator() bez using nikt Dispose() nie zawoła i finally nie wykona się wcale.

Zagadka #6: async nie znaczy „w tle”

Console.WriteLine("przed wywolaniem");
Task zadanie = PobierzAsync();
Console.WriteLine("po wywolaniu");
await zadanie;
Console.WriteLine("koniec");

async Task PobierzAsync()
{
    Console.WriteLine("start metody");
    await Task.Delay(100);
    Console.WriteLine("po await");
}

Odpowiedź: przed wywolaniem, start metody, po wywolaniu, po await,  koniec.

Wywołanie metody asynchronicznej wchodzi w jej ciało i wykonuje je synchronicznie
– na tym samym wątku, w tym samym momencie — aż do pierwszego await, który faktycznie na coś czeka. Dlatego start metody pojawia się przed po wywolaniu, i dlatego nie ma tu żadnego nowego wątku, mimo słowa async.

Dopiero await Task.Delay(100) oddaje sterowanie do miejsca wywołania: metoda zwraca niedokończony Task, a program leci dalej. Reszta ciała metody (po await) to
kontynuacja – kod doklejony do zadania i wykonany, gdy opóźnienie minie. await zadanie czeka na ten moment, więc koniec zawsze jest ostatni.

Zasada: 
żeby coś ruszyło naprawdę równolegle, najpierw wystartuj wszystkie zadania, a dopiero potem czekaj na nie (Task.WhenAll). await w środku pętli
zamienia równoległość w kolejkę.

Krok po kroku

W jakiej kolejności wykonuje się ten kod?

Console.WriteLine("przed wywolaniem"); Task zadanie = PobierzAsync(); Console.WriteLine("po wywolaniu"); await zadanie; Console.WriteLine("koniec"); async Task PobierzAsync() { Console.WriteLine("start metody"); await Task.Delay(100); Console.WriteLine("po await"); }
konsola pusta — kliknij „Dalej”
Osiem kliknięć, osiem kroków. Zgadnij, która linia wykona się jako druga — i dlaczego to nie jest ta pod spodem.
krok 0 / 8

Zagadka #7: .Result kontra await — ta z początku

try
{
    var cena = PobierzCeneAsync().Result;
}
catch (Exception ex)
{
    Console.WriteLine(ex.GetType().Name);
}

try
{
    var cena = await PobierzCeneAsync();
}
catch (Exception ex)
{
    Console.WriteLine(ex.GetType().Name);
}

async Task<int> PobierzCeneAsync()
{
    await Task.Yield();
    throw new InvalidOperationException("brak ceny");
}

Odpowiedź: AggregateException, potem InvalidOperationException.

Task może reprezentować wiele operacji naraz — choćby Task.WhenAll po dziesięciu zadaniach, z których trzy wybuchły — więc trzyma listę wyjątków. Blokujące .Result (tak samo .Wait()) oddaje tę listę dokładnie w tej postaci: opakowaną w AggregateException.

I tu wraca zagadka z początku postu. Jeżeli w tym miejscu napiszesz catch(InvalidOperationException), ten blok nie złapie niczego – typ się nie
zgadza. Wyjątek poleci w górę stosu, a Ty zobaczysz pustą stronę, błąd 500 albo nic,
jeśli ktoś wyżej połknie błąd.

await zachowuje się inaczej: wyciąga pierwszy wyjątek z listy i rzuca go ponownie
w oryginalnej postaci, razem z zachowanym stosem wywołań. Dlatego kod asynchroniczny łapie się dokładnie tak, jakbyś napisał go synchronicznie.

Regułę „nigdy nie blokuj na .Result” słyszy się głównie w kontekście zakleszczeń –
w ASP.NET Framework i WinForms, gdzie istnieje kontekst synchronizacji. W aplikacji
konsolowej zakleszczenia nie ma, więc łatwo uznać, że problem nie dotyczy Twojego kodu.

Zasada: 
Result podmienia typ wyjątku niezależnie od typu aplikacji.
Istniejące bloki catch cicho przestają działać i to jest drugi, rzadziej
wymieniany powód, żeby nie blokować.

Zagadka #8: operator ?. i efekt uboczny w argumencie

int licznik = 0;

string? tekst = null;
var wynik = tekst?.Substring(Nastepny());

Console.WriteLine(licznik);
Console.WriteLine(wynik is null);

int Nastepny()
{
    licznik++;
    return licznik;
}

Odpowiedź: 0, potem True.

?. nie jest skrótem na „jeśli nie null, to wywołaj” z osobno policzonym argumentem.
Operator warunkowego dostępu przerywa całe wyrażenie, którego jest częścią — razem z wyliczaniem argumentów wywołania i kolejnymi ogniwami łańcucha. Skoro tekst jest nullem, Nastepny() nigdy się nie wykonuje i licznik zostaje na zerze.

Drugie zaskoczenie siedzi w typie: Substring zwraca string, ale całe wyrażenie
z ?. jest nullowalne, dlatego wynik to string? i jest nullem, zamiast rzucić
wyjątek. Gdyby metoda zwracała int, wynikiem byłoby int?.

Zasada: 
nie chowaj efektów ubocznych w argumentach wywołania po ?. – logowanie,
licznik metryk czy pobranie z kolejki cicho przestaną się wykonywać, gdy tylko lewa strona łańcucha okaże się nullem.

Zagadka #9: referencja przekazana przez wartość

var produkty = new List<string> { "mysz" };

Wyczysc(produkty);
Dodaj(produkty);

Console.WriteLine(produkty.Count);
Console.WriteLine(string.Join(", ", produkty));

void Wyczysc(List<string> lista)
{
    lista = new List<string>();
    lista.Add("nowy");
}

void Dodaj(List<string> lista)
{
    lista.Add("dodany");
}

Odpowiedź: 2, potem mysz, dodany.

C# przekazuje argumenty przez wartość – również wtedy, gdy argument jest typem
referencyjnym. Metoda dostaje własną kopię referencji: drugą kartkę z tym samym
adresem.

Dodaj idzie pod ten adres i zmienia obiekt, który tam leży, więc zmiana jest widoczna
na zewnątrz. Wyczysc nadpisuje swoją kartkę nowym adresem i od tej chwili pracuje
na obiekcie, o którym nikt poza nią nie wie – oryginalna lista zostaje nietknięta,
a nowa ginie po wyjściu z metody.

Zasada: 
„typ referencyjny” nie znaczy „przekazywany przez referencję”. Jeśli podmiana samego obiektu ma być widoczna na zewnątrz, potrzebujesz ref: void Wyczysc(ref List<string> lista) i wywołanie Wyczysc(ref produkty).

Ściągawka — wszystkie 9 odpowiedzi na jednej liście

#ZagadkaOdpowiedź
1record kontra class przy ==True, potem False
2with i wspólna lista2, potem True
3new kontra overrideBazowy, potem Pochodny
4dwie enumeracje tego samego Where...2 i ...2 (sześć kropek)
5yield return + finally po breakstart, 1, finally, po foreach
6kolejność wokół awaitprzed wywolaniem, start metody, po wywolaniu, po await, koniec
7.Result kontra awaitAggregateException, potem InvalidOperationException
8?. z efektem ubocznym w argumencie0, potem True
9referencja przekazana przez wartość2, potem mysz, dodany
Ściągawka

Dziewięć odpowiedzi — policz swój wynik

Odsłoń odpowiedź, a potem zaznacz, czy trafiłeś. Wyniki pochodzą z faktycznego uruchomienia na .NET 10.

01Ta sama zawartość w rekordzie i w klasie — co wypisze ==?
True FalseRekord dostaje wygenerowaną równość strukturalną razem z operatorami == i !=. Klasa bez przeciążenia porównuje referencje.
02Dopisujemy do listy w kopii zrobionej przez with — ile elementów ma oryginał?
2 TrueKopia jest płytka — lista zostaje wspólna, więc ReferenceEquals na obu listach daje True.
03Obiekt klasy Pochodny w zmiennej typu Bazowy, metoda ukryta przez new.
Bazowy PochodnyUkrywanie wybiera się po typie zmiennej, nadpisywanie po typie obiektu. Bez słowa new byłoby ostrzeżenie CS0108.
04Zapytanie LINQ z Console.Write w lambdzie, dwa razy Count() — ile kropek?
…2 …2Sześć kropek. Zapytanie to przepis, nie wynik — każda enumeracja przechodzi po źródle od nowa.
05Iterator z try/finally, pętla przerwana przez break.
start 1 finally po foreachPrzy wyjściu z pętli foreach woła Dispose() na enumeratorze, a to wykonuje blok finally. Linia „koniec petli” nie wykonuje się nigdy.
06Pięć napisów wokół jednego await — w jakiej kolejności?
przed wywolaniem start metody po wywolaniu po await koniecCiało metody async biegnie synchronicznie aż do pierwszego await, który naprawdę czeka.
07Ten sam wyjątek złapany raz po .Result, raz po await — nazwy typów?
AggregateException InvalidOperationExceptionBlokujące .Result oddaje listę wyjątków zadania w opakowaniu. await wyciąga pierwszy i rzuca go w oryginalnej postaci.
08tekst jest nullem, a w argumencie Substring siedzi metoda zwiększająca licznik.
0 TrueOperator ?. przerywa całe wyrażenie razem z wyliczaniem argumentów — metoda nigdy się nie wykonuje.
09Jedna metoda podmienia listę, druga do niej dopisuje.
2 mysz, dodanyArgument idzie przez wartość — metoda dostaje kopię referencji. Podmiana widoczna byłaby dopiero przy ref.
0/9Zaznacz swoje trafienia — wynik policzy się sam.

Zadanie: zepsuj to celowo

Weź zagadkę 7 i zamień w pierwszym bloku .Result na await, nie ruszając niczego
więcej. Uruchom. Potem zrób odwrotnie w drugim bloku. Zobaczysz, że jedyną różnicą między „mój catch działa” a „mój catch jest martwy” jest to jedno słowo.

Na deser: w tym samym pliku zmień catch (Exception ex) na
catch(InvalidOperationException ex) w obu blokach i przeczytaj komunikat, który dostaniesz. To dokładnie ten scenariusz, od którego zaczyna się ten post.

Podsumowanie

  • record dostaje od kompilatora równość strukturalną i operatory ==/!=;
    zwykła class porównuje referencje. To nie jest różnica „stos kontra sterta”.
  • with kopiuje płytko – kolekcja w rekordzie zostaje wspólna dla kopii
    i oryginału.
  • new ukrywa metodę, override ją nadpisuje. Ukrywanie wybiera się po typie
    zmiennej, nadpisywanie po typie obiektu.
  • IEnumerable<T> to przepis, nie wynik – każde Count(), Any() i foreach
    wykonuje zapytanie od nowa, ze wszystkimi efektami ubocznymi.
  • async nie znaczy „w tle”: ciało metody biegnie synchronicznie aż do pierwszego
    realnego await.
  • .Result opakowuje wyjątek w AggregateException i tym samym wyłącza istniejące bloki catch – niezależnie od tego, czy w danej aplikacji grozi zakleszczenie.

Jeśli zgadłeś więcej niż 6 z 9 – solidny wynik. Jeśli wszystkie dziewięć, napisz
o tym w komentarzu pod filmem, bo siódemkę trafia naprawdę niewiele osób.

Zobacz pełny odcinek na YouTube, gdzie każda z tych zagadek ma dodatkowo pauzę na Twoją odpowiedź przed odsłoną – spróbuj zgadnąć na czas, zanim usłyszysz wynik.

🎓 Jeśli chcesz zbudować z takich szczegółów pełną wiedzę produkcyjną, a nie zbiór
ciekawostek — sprawdź Program .NET Developer albo pobierz
darmową roadmapę Junior .NET Developer.

Zobacz

Mariusz Jurczenko
Mariusz Jurczenko
Senior .NET Developer · 10+ lat doświadczenia komercyjnego

Programista .NET z doświadczeniem komercyjnym w firmach takich jak NFZ, Kamsoft, Diagnostyka, Hermes Reply Polska czy Etisoft Smart Solutions. Twórca kursów, z których skorzystało już ponad 11 000 osób w Strefie Kursów i ponad 1 000 kursantów na dev-hobby.pl.

Specjalizacja: Clean Code, Clean Architecture i uczenie programowania tak, żeby dało się je naprawdę zrozumieć — nie wykuć.

🚀 Co dalej?

Zobacz to w praktyce na wideo i pobierz darmową roadmapę, żeby ułożyć naukę w spójną ścieżkę do pierwszej pracy.

Dodaj komentarz

czytanie to początek

Zamień wiedzę w umiejętności

Pobierz darmową Roadmapę .NET i ułóż takie tematy jak ten w spójną ścieżkę do pierwszej pracy.

Pobieram roadmapę →