Pułapki C# – quiz: zgadnij output 9 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żywajdecimal, niedouble. 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#.
object a = 1000; object b = 1000;
Co wypisze ta konsola?
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.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:
ReferenceEqualsi==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> i 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ŁĄCZNIEforeach.
Jeśli budujesz listę callbacków w klasycznej pętlifori potrzebujesz osobnej
wartości na iterację – skopiuj zmienną do lokalnej wewnątrz ciała pętli:int local = i;i buduj domknięcie nadlocal, nie nadi.
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.
Ta sama pętla, inna zmienna — obejrzyj domknięcia w akcji
Kliknij „Następny krok”, żeby dodać domknięcie do listy.
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
| # | Zagadka | Odpowiedź |
|---|---|---|
| 1 | 0.1 + 0.2 == 0.3 | False |
| 2 | a++ + ++a (a=5) | 12, potem 7 |
| 3 | (int?)null == (int?)null | True |
| 4 | object boxing == | False |
| 5 | string interning | True, potem False |
| 6 | kowariancja tablic | kompiluje się, ArrayTypeMismatchException w runtime |
| 7 | domknięcia for vs foreach | "333", potem "012" |
| 8 | LINQ leniwe wykonanie | "2 3 10 " |
| 9 | przepełnienie int (unchecked) | 2147483647, potem -2147483648, brak wyjątku |
Odkryj odpowiedzi jedna po drugiej
Odkryto: 0/9False — binarny double nie zapisuje 0.1 ani 0.2 dokładnie.
12, potem 7 — C# liczy od lewej do prawej: 5 + 7, a zmienna kończy na siódemce.
True — podniesiony operator == dla Nullable<T>.
False — porównanie referencji dwóch oddzielnie zboxowanych obiektów.
True, potem False — interning literałów, nowy obiekt przy new string.
Kompiluje się. ArrayTypeMismatchException w runtime (kowariancja tablic).
"333", potem "012" — C# 5 naprawił TYLKO foreach.
"2 3 10 " — leniwe wykonanie widzi listę w momencie iteracji.
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.
==naobjectto referencje,==nastringto 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 klasycznymfor-
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łnienieintzawija się cicho, chyba że jawnie włączyszchecked.
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
🚀 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 krokó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ę →