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

Pułapki C# – quiz: zgadnij output 9 fragmentów kodu

Quiz z kodem C# — zgadnij output dziewięciu fragmentów kodu

Ten kod ma trzy linijki i wypisuje „333″. Nie „012″. Jeśli pomyślałeś „012″ —
właśnie wpadłeś w błąd, który realnie trafia na produkcję, i to nie raz.

To pierwszy post-quiz na blogu, towarzyszący nowej serii YouTube Zgadnij Output.
Dziewięć fragmentów kodu C#, dziewięć pytań „co wypisze konsola” albo „czy się
skompiluje”, i przy każdym – pełne wyjaśnienie mechanizmu, nie tylko odpowiedź.
Format sprawdza się najlepiej, gdy faktycznie spróbujesz zgadnąć PRZED przewinięciem do wyjaśnienia, więc potraktuj to poważnie, zanim czytasz dalej.

Każdy fragment jest kompilowalnym C# i pochodzi 1:1 z kodu towarzyszącego odcinkowi na YouTube – zero uproszczeń między wideo a tekstem.

Pułapka #1: 0.1 + 0.2 to nie 0.3

double a = 0.1 + 0.2;
double b = 0.3;
Console.WriteLine(a == b);

Odpowiedź: False.

0.1 i 0.2 nie mają dokładnej reprezentacji w binarnym formacie zmiennoprzecinkowym IEEE 754, na którym działa double. Suma w pamięci to 0.30000000000000004 — o włos różne od literału 0.3. To nie jest błąd C# ani .NET — dokładnie to samo zobaczysz w Pythonie, JavaScripcie, Javie i C++.

Zasada: 
dla pieniędzy i wartości wymagających dokładności co do grosza używaj
decimal, nie double. Dla porównań double, których nie da się uniknąć, porównuj
z tolerancją – Math.Abs(a - b) < epsilon — nigdy operatorem ==.

Pułapka #2: kolejność ewaluacji przy inkrementacji

int a = 5;
int b = a++ + ++a;
Console.WriteLine(b);
Console.WriteLine(a);

Odpowiedź: 12, potem 7.

C# gwarantuje ewaluację operandów od lewej do prawej – w przeciwieństwie do C
i C++, gdzie kolejność wyliczania takich wyrażeń jest niezdefiniowana (dwa różne
kompilatory C++ mogą dać różny wynik dla identycznego kodu). W C# to zachowanie jest częścią specyfikacji języka, nie szczegółem implementacyjnym konkretnego kompilatora.

Krok po kroku: a++ zwraca aktualną wartość a (5), dopiero potem zwiększa a do 6.
++a najpierw zwiększa a (6 → 7), zwraca już nową wartość. Suma: 5 + 7 = 12 – to
wypisuje pierwsza linia. Druga pokazuje samo a: siódemkę, czyli dwa zwiększenia
od startowej piątki. Więcej o tym, jak operatory naprawdę się zachowują w C#: operatory w C#.

Pułapka #3: null porównany z null

int? a = null;
int? b = null;
Console.WriteLine(a == b);

Odpowiedź: True.

Operator == dla Nullable<T> jest „podniesiony” (lifted) przez kompilator – jeśli
oba operandy są null, wynik to true. To jedna z niewielu sytuacji w C#, gdzie
porównanie null z null jest jednoznacznie zdefiniowane jako prawda. Dla kontrastu:
w SQL NULL = NULL daje NULL (nieznane), nie TRUE. Różnica między Nullable<T>
a typami referencyjnymi z adnotacjami null (string?) jest częstym źródłem pomyłek
— więcej w poście o Nullable<T> kontra nullable reference types.

Pułapka #4: boxing i porównanie referencji

object a = 1000;
object b = 1000;
Console.WriteLine(a == b);

Odpowiedź: False.

Statyczny (czasu kompilacji) typ obu zmiennych to object, nie int. Operator ==
dla object to porównanie referencji, bo żaden bardziej szczegółowy operator nie
pasuje do dwóch zmiennych zadeklarowanych jako object. Każde przypisanie int-a do object to boxing – utworzenie nowego obiektu na stercie. Dwa oddzielne boxowania tej samej wartości dają dwa różne obiekty, więc porównanie referencji wychodzi false.

Jeśli programowałeś w Javie, prawdopodobnie liczyłeś na cache dla małych liczb
całkowitych – Java go ma, dla wartości od -128 do 127. C# nie ma żadnego takiego
cache’a
 dla boxowanych wartości. Wynik jest false niezależnie, czy to 5, czy 1000.
Mechanikę stack/heap i samego boxingu tłumaczę dokładniej w poście o
value types kontra reference types, a konwersje między typami — w poście o konwersjach typów w C#.

Zgadnij, zanim klikniesz

object a = 1000; object b = 1000;

object a = 1000; object b = 1000; Console.WriteLine(a == b);

Co wypisze ta konsola?

Prawie — poprawna odpowiedź to False. Statyczny typ obu zmiennych to object, więc == porównuje referencje, nie wartości. Każde przypisanie inta do object to nowy boxing — dwa oddzielne obiekty na stercie, więc porównanie referencji wychodzi false. Szczegóły w sekcji „Pułapka #4″ wyżej.
Zgadłeś! To False. Statyczny typ obu zmiennych to object, więc == porównuje referencje. Każde przypisanie inta do object to boxing — nowy obiekt na stercie za każdym razem. C# (w przeciwieństwie do Javy) nie ma cache’a dla boxowanych wartości.

Pułapka #5: stringi, interning i nowy obiekt

string a = "hello";
string b = "hel" + "lo";
string c = new string("hello".ToCharArray());

Console.WriteLine(ReferenceEquals(a, b));
Console.WriteLine(ReferenceEquals(a, c));

Odpowiedź: True, potem False.

"hel" + "lo" to złączenie dwóch stałych znanych w czasie kompilacji. Kompilator C# wykonuje „constant folding” zamienia to na jeden literał "hello" jeszcze przed
uruchomieniem programu. Wszystkie literały stringowe w assembly są automatycznie
internowane przez CLR, więc a i b wskazują na dokładnie ten sam obiekt w pamięci.
Stąd pierwsze True.

new string(...) jawnie tworzy nowy obiekt na stercie przez konstruktor String,
z pominięciem puli internowanych stringów. Ta sama treść znaków, inny obiekt –
ReferenceEquals daje False. Ciekawostka: a == c dałoby True, bo string ma
przeciążony operator == porównujący wartość, nie referencję – dokładnie odwrotnie
niż object w Pułapce #4.

Zasada: 
ReferenceEquals i == to nie to samo, a odpowiedź zależy od TYPU STATYCZNEGO zmiennych, nie od tego, co “logicznie” w nich siedzi.

Pułapka #6: tablica, która kłamie kompilatorowi

object[] arr = new string[3];
arr[0] = 42;

Odpowiedź: kompiluje się bez żadnego ostrzeżenia.
W runtime rzuca ArrayTypeMismatchException.

Tablice typów referencyjnych w .NET są kowariantne – string[] można przypisać
do zmiennej object[], bo string dziedziczy po object. To pozwala kompilatorowi
przepuścić arr[0] = 42 (42 podlega boxingowi do object, a object pasuje do
deklarowanego typu tablicy).

Problem:
pod spodem ta tablica wciąż fizycznie jest tablicą stringów. CLR wykonuje sprawdzenie typu elementu przy każdym zapisie do tablicy referencyjnej. Zboxowany int nie pasuje do string[] — wyjątek w runtime, mimo że kompilator nie zgłosił ani jednego ostrzeżenia.

To klasyczny powód, dla którego generyki są bezpieczniejsze niż tablice – List<object>List<string> to dla kompilatora zupełnie niekompatybilne typy, bez tego rodzaju kowariancji.

Pułapka #7: domknięcia w for kontra foreach (główna zagadka)

var actionsFor = new List<Action>();
for (int i = 0; i < 3; i++)
    actionsFor.Add(() => Console.Write(i));
foreach (var a in actionsFor) a();

var actionsForeach = new List<Action>();
foreach (var x in new[] { 0, 1, 2 })
    actionsForeach.Add(() => Console.Write(x));
foreach (var a in actionsForeach) a();

Odpowiedź: "333", potem "012".

C# 5.0, wydany w 2012 roku, naprawił domknięcia w foreach – od tamtej wersji każda iteracja foreach dostaje własną, świeżą zmienną iteracyjną. Domknięcie zamyka nad wartością z konkretnej iteracji. Stąd drugi blok: "012".

Ta poprawka nigdy nie objęła klasycznej pętli for. Zmienna i, zadeklarowana
w nagłówku for, to jedna zmienna, której zakres to cała pętla – mutowana przy
każdym obrocie. Wszystkie trzy domknięcia w pierwszym bloku zamykają nad tą samą zmienną. Kiedy pętla się kończy, i ma wartość 3 (warunek i < 3 staje się fałszywy właśnie przy i równym 3) – wszystkie trzy wywołania wypisują „3″. Stąd "333".

Zasada: 
„C# 5 naprawił domknięcia w pętlach” dotyczy WYŁĄCZNIE foreach.
Jeśli budujesz listę callbacków w klasycznej pętli for i potrzebujesz osobnej
wartości na iterację – skopiuj zmienną do lokalnej wewnątrz ciała pętli:
int local = i; i buduj domknięcie nad local, nie nad i.

To jest realny błąd produkcyjny, nie akademicka ciekawostka, jeśli kiedykolwiek
rejestrowałeś handlery zdarzeń albo callbacki w pętli for i wszystkie “strzelały”
z tą samą, ostatnią wartością, to jest dokładnie ten mechanizm.

Krok po kroku

Ta sama pętla, inna zmienna — obejrzyj domknięcia w akcji

Kliknij „Następny krok”, żeby dodać domknięcie do listy.

Konsola — naciśnij „Wywołaj wszystkie” na końcu…

Pułapka #8: LINQ i leniwe wykonanie

var list = new List<int> { 1, 2, 3 };
var query = list.Where(x => x > 1);
list.Add(10);
foreach (var item in query) Console.Write(item + " ");

Odpowiedź: "2 3 10 ".

Where (jak większość LINQ to Objects) ma leniwe, odroczone wykonanie. Zdefiniowanie query nic nie przelicza - to tylko „przepis" na filtrowanie,
wykonywany dopiero w momencie faktycznej iteracji (foreach). Między zdefiniowaniem zapytania a jego wyliczeniem dopisaliśmy do list element 10. Ponieważ enumeracja startuje po tym dopisaniu, Where widzi już czteroelementową listę i filtruje: 2, 3, 10. Gdyby zamiast tego wywołać .ToList() od razu po Where, zapytanie wykonałoby się natychmiast na wtedy jeszcze trzyelementowej liście, a 10 nigdy by się nie pojawiło w wyniku.

Pułapka #9: przepełnienie int bez wyjątku

int max = int.MaxValue;
Console.WriteLine(max);
Console.WriteLine(max + 1);

Odpowiedź: 2147483647, potem -2147483648 (int.MinValue). Żadnego wyjątku.

Domyślnym kontekstem arytmetycznym w C# jest unchecked - szablon dotnet new console nie włącza <CheckForOverflowUnderflow>true</...> w pliku projektu. W tym kontekście przepełnienie int po prostu „zawija się" na drugą stronę zakresu
(arytmetyka modulo 2³², reprezentacja w kodzie uzupełnień do dwóch), bez żadnego
wyjątku ani ostrzeżenia. Żeby dostać w tym miejscu OverflowException, trzeba jawnie opakować wyrażenie w blok checked { ... } albo włączyć sprawdzanie przepełnienia globalnie w projekcie.

Zasada: 
to ustawienie bywa źródłem cichych błędów finansowych i liczbowych,
jeśli ktoś zakłada, że .NET "sam zauważy" przepełnienie.
Nie zauważy, chyba że go o to jawnie poprosisz.

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

#ZagadkaOdpowiedź
10.1 + 0.2 == 0.3False
2a++ + ++a (a=5)12, potem 7
3(int?)null == (int?)nullTrue
4object boxing ==False
5string interningTrue, potem False
6kowariancja tablickompiluje się, ArrayTypeMismatchException w runtime
7domknięcia for vs foreach"333", potem "012"
8LINQ leniwe wykonanie"2 3 10 "
9przepełnienie int (unchecked)2147483647, potem -2147483648, brak wyjątku

Odkryj odpowiedzi jedna po drugiej

Odkryto: 0/9
1. 0.1 + 0.2 == 0.3

False — binarny double nie zapisuje 0.1 ani 0.2 dokładnie.

2. int b = a++ + ++a; (a=5)

12, potem 7 — C# liczy od lewej do prawej: 5 + 7, a zmienna kończy na siódemce.

3. (int?)null == (int?)null

True — podniesiony operator == dla Nullable<T>.

4. object a=1000; object b=1000; a==b

False — porównanie referencji dwóch oddzielnie zboxowanych obiektów.

5. ReferenceEquals: literał vs new string(...)

True, potem False — interning literałów, nowy obiekt przy new string.

6. object[] arr = new string[3]; arr[0] = 42;

Kompiluje się. ArrayTypeMismatchException w runtime (kowariancja tablic).

7. Domknięcia: for vs foreach

"333", potem "012" — C# 5 naprawił TYLKO foreach.

8. LINQ Where + Add PRZED foreach

"2 3 10 " — leniwe wykonanie widzi listę w momencie iteracji.

9. int.MaxValue + 1 (unchecked)

2147483647, potem -2147483648, bez wyjątku — przepełnienie zawija się domyślnie.

Podsumowanie

  • C# gwarantuje ewaluację od lewej do prawej - inaczej niż C/C++, gdzie kolejność bywa niezdefiniowana.
  • == na object to referencje, == na string to wartość - statyczny typ
    zmiennej decyduje, nie to, co w niej "logicznie" siedzi.
  • Boxing zawsze tworzy nowy obiekt na stercie - C# nie ma cache'a jak Java.
  • C# 5 naprawił domknięcia WYŁĄCZNIE w foreach, nigdy w klasycznym for -
    to jest realny, powtarzalny błąd produkcyjny.
  • Kowariancja tablic pozwala kompilatorowi przepuścić kod, który wybuchnie dopiero w runtime - generyki (List<T>) tego unikają.
  • LINQ to Objects jest leniwe: modyfikacje kolekcji między definicją zapytania
    a jego wyliczeniem są widoczne w wyniku.
  • Domyślna arytmetyka w C# jest unchecked - przepełnienie int zawija się cicho, chyba że jawnie włączysz checked.

Jeśli zgadłeś więcej niż 6 z 9 - solidny wynik. Jeśli zgadłeś wszystkie 9 napisz
o tym w komentarzu pod filmem, bo to się rzadko zdarza nawet doświadczonym .NET-owcom.

To pierwszy odcinek nowego formatu na kanale, kolejna porcja zagadek pojawi się niebawem. Zobacz pełny odcinek na YouTube, gdzie każda z tych dziewięciu zagadek ma dodatkowo napięcie przed odsłoną (i spróbuj zgadnąć na czas, zanim usłyszysz odpowiedź).

🎓 Jeśli podobają Ci się takie szczegóły języka i chcesz zbudować z nich pełną wiedzę produkcyjną, a nie tylko ciekawostki - sprawdź kurs Od Zera do .NET Developera 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ę →