Rekordy, LINQ i async – quiz: zgadnij output 9 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 —recordistringmają, zwykłaclassnie.
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) }.
Kopia rekordu, jedna lista
Klawiaturę dopisujemy do kopii. Co wypisze pierwsza linia — ile elementów ma lista w oryginale?
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.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 piszesznewnieś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łasnetry/finally i przy każdym wyjściu woła Dispose() na enumeratorze. To właśnieDispose() 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:
finallyw 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ęcznymGetEnumerator()bezusingniktDispose()nie zawoła ifinallynie 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).awaitw środku pętli
zamienia równoległość w kolejkę.
W jakiej kolejności wykonuje się ten kod?
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:
Resultpodmienia typ wyjątku niezależnie od typu aplikacji.
Istniejące blokicatchcicho 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, potrzebujeszref:void Wyczysc(ref List<string> lista)i wywołanieWyczysc(ref produkty).
Ściągawka — wszystkie 9 odpowiedzi na jednej liście
| # | Zagadka | Odpowiedź |
|---|---|---|
| 1 | record kontra class przy == | True, potem False |
| 2 | with i wspólna lista | 2, potem True |
| 3 | new kontra override | Bazowy, potem Pochodny |
| 4 | dwie enumeracje tego samego Where | ...2 i ...2 (sześć kropek) |
| 5 | yield return + finally po break | start, 1, finally, po foreach |
| 6 | kolejność wokół await | przed wywolaniem, start metody, po wywolaniu, po await, koniec |
| 7 | .Result kontra await | AggregateException, potem InvalidOperationException |
| 8 | ?. z efektem ubocznym w argumencie | 0, potem True |
| 9 | referencja przekazana przez wartość | 2, potem mysz, dodany |
Dziewięć odpowiedzi — policz swój wynik
Odsłoń odpowiedź, a potem zaznacz, czy trafiłeś. Wyniki pochodzą z faktycznego uruchomienia na .NET 10.
==?== i !=. Klasa bez przeciążenia porównuje referencje.with — ile elementów ma oryginał?ReferenceEquals na obu listach daje True.Pochodny w zmiennej typu Bazowy, metoda ukryta przez new.new byłoby ostrzeżenie CS0108.Console.Write w lambdzie, dwa razy Count() — ile kropek?try/finally, pętla przerwana przez break.foreach woła Dispose() na enumeratorze, a to wykonuje blok finally. Linia „koniec petli” nie wykonuje się nigdy.await — w jakiej kolejności?async biegnie synchronicznie aż do pierwszego await, który naprawdę czeka..Result, raz po await — nazwy typów?.Result oddaje listę wyjątków zadania w opakowaniu. await wyciąga pierwszy i rzuca go w oryginalnej postaci.tekst jest nullem, a w argumencie Substring siedzi metoda zwiększająca licznik.?. przerywa całe wyrażenie razem z wyliczaniem argumentów — metoda nigdy się nie wykonuje.ref.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) nacatch(InvalidOperationException ex) w obu blokach i przeczytaj komunikat, który dostaniesz. To dokładnie ten scenariusz, od którego zaczyna się ten post.
Podsumowanie
recorddostaje od kompilatora równość strukturalną i operatory==/!=;
zwykłaclassporównuje referencje. To nie jest różnica „stos kontra sterta”.withkopiuje płytko – kolekcja w rekordzie zostaje wspólna dla kopii
i oryginału.newukrywa metodę,overrideją nadpisuje. Ukrywanie wybiera się po typie
zmiennej, nadpisywanie po typie obiektu.IEnumerable<T>to przepis, nie wynik – każdeCount(),Any()iforeach
wykonuje zapytanie od nowa, ze wszystkimi efektami ubocznymi.asyncnie znaczy „w tle”: ciało metody biegnie synchronicznie aż do pierwszego
realnegoawait..Resultopakowuje wyjątek wAggregateExceptioni tym samym wyłącza istniejące blokicatch– 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
🚀 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.
- 🗺️ Pobierz darmową roadmapę Junior .NET Developer — 12 poziomów od podstaw C# do pierwszej pracy: dev-hobby.pl
- 🎬 Subskrybuj kanał YouTube — nowe filmy co tydzień.
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ę →