Value types vs reference types w C# – stack, heap i boxing na przykładach
Value types vs reference types w C# — stack, heap i boxing na przykładach
Napisz taki kod i zapytaj o wynik dowolnego początkującego programistę:
struct PunktStruct { public int X; }
class PunktClass { public int X; }
var s1 = new PunktStruct { X = 1 };
var s2 = s1;
s2.X = 99;
var c1 = new PunktClass { X = 1 };
var c2 = c1;
c2.X = 99;
Console.WriteLine(s1.X); // ?
Console.WriteLine(c1.X); // ?
Jeśli nie wiesz od razu, że pierwsza linia wypisze 1, a druga 99 — ten artykuł jest dla Ciebie. Jeśli wiesz, ale nie potrafisz wytłumaczyć dlaczego — tym bardziej. To nie jest ciekawostka akademicka. To fundament, na którym stoi cała reszta C#: dlaczego struct bywa szybszy niż class, dlaczego List<int> różni się wydajnościowo od ArrayList, i dlaczego mutowalny struct w kolekcji potrafi wysadzić Ci logikę biznesową bez żadnego wyjątku w konsoli.
Dwie kategorie typów w C#
W C# każdy typ należy do jednej z dwóch kategorii:
- Typ wartościowy (value type) — zmienna przechowuje bezpośrednio wartość. Należą do nich m.in.
int,double,bool,char, wszystkiestruct(w tymDateTime,Guid,decimal) orazenum. - Typ referencyjny (reference type) — zmienna przechowuje adres, pod którym w pamięci leży rzeczywisty obiekt. Należą do nich
class,string,interface,delegate, tablice i wszystkie typy zSystem.Collections.Generic(List<T>,Dictionary<TKey, TValue>itd.).
To rozróżnienie nie jest kwestią stylu — jest wpisane w system typów przez wspólnego przodka. Wszystkie typy wartościowe dziedziczą (niejawnie) po System.ValueType, który z kolei dziedziczy po System.Object. Typy referencyjne dziedziczą po System.Object bezpośrednio lub przez łańcuch klas bazowych. Ta różnica w hierarchii typów przekłada się bezpośrednio na to, co się dzieje w pamięci.
Stack i heap — gdzie faktycznie żyją dane
Runtime .NET zarządza pamięcią w dwóch podstawowych obszarach:
Stack to uporządkowany, liniowy obszar pamięci powiązany z aktualnie wykonywanym wątkiem. Każde wejście do metody tworzy nową ramkę stosu (stack frame), w której lądują zmienne lokalne typów wartościowych i parametry. Wyjście z metody automatycznie zwalnia tę ramkę — bez udziału Garbage Collectora. To dlatego operacje na stosie są tak tanie: alokacja to po prostu przesunięcie wskaźnika stosu, a zwolnienie — przesunięcie go z powrotem.
Heap (sterta zarządzana) to obszar, w którym żyją instancje typów referencyjnych. Alokacja na stercie jest droższa niż na stosie, a zwolnienie pamięci nie następuje natychmiast po wyjściu ze scope’u — zajmuje się tym Garbage Collector, w momencie, który sam wybiera.
Uproszczenie, które warto od razu obalić: “typy wartościowe zawsze są na stosie” — to nieprawda. Typ wartościowy będący polem klasy albo elementem tablicy typów referencyjnych trafia na stertę razem z obiektem, który go przechowuje:
class Zamowienie
{
public int Ilosc; // int, ale leży na stercie — jest polem obiektu na stercie
}
Dokładniejsza reguła brzmi: zmienna lokalna typu wartościowego żyje tam, gdzie żyje kontener, w którym się znajduje. Jeśli kontenerem jest ramka stosu metody — trafia na stos. Jeśli kontenerem jest obiekt na stercie — trafia na stertę razem z nim.
Semantyka kopiowania — sedno różnicy
To jest właściwy powód, dla którego ten podział ma znaczenie w codziennej pracy, nie ciekawostka o pamięci:
struct Konto
{
public decimal Saldo;
}
class KontoBankowe
{
public decimal Saldo;
}
// Value type — przypisanie kopiuje CAŁĄ wartość
Konto k1 = new Konto { Saldo = 1000m };
Konto k2 = k1; // kopia niezależna
k2.Saldo = 500m;
Console.WriteLine(k1.Saldo); // 1000 — k1 nietknięte
// Reference type — przypisanie kopiuje REFERENCJĘ (adres)
KontoBankowe kb1 = new KontoBankowe { Saldo = 1000m };
KontoBankowe kb2 = kb1; // ten sam obiekt, dwie zmienne
kb2.Saldo = 500m;
Console.WriteLine(kb1.Saldo); // 500 — to ten sam obiekt!
Ta sama zasada dotyczy przekazywania argumentów do metod (domyślnie przez wartość) oraz zwracania wartości z metod. Jeśli chcesz, żeby metoda zmodyfikowała oryginalny typ wartościowy przekazany jako argument, musisz to zrobić jawnie przez ref — o czym więcej w osobnym artykule o ref, out i params.
Konsekwencja produkcyjna: jeśli używasz struct jako DTO przekazywanego w wielu warstwach aplikacji i modyfikujesz go “po drodze”, licząc na to, że zmiana się propaguje — nie propaguje się. To jeden z częstszych bugów u developerów przechodzących z Javy (gdzie wszystko poza prymitywami jest referencją) do C#.
struct vs class — kiedy wybrać które
| Kryterium | struct (value type) | class (reference type) |
|---|---|---|
| Domyślna semantyka | kopiowanie wartości | współdzielenie referencji |
| Alokacja | stos (jeśli zmienna lokalna) | zawsze sterta |
| Dziedziczenie | brak (implicit sealed) | pełne wsparcie |
| Nullability | nie może być null (poza Nullable<T>) | może być null |
| Rozmiar w pamięci | powinien być mały (rekomendacja: ≤16 bajtów) | dowolny |
| Typowe zastosowanie | DateTime, Guid, współrzędne, pieniądze, klucze do słowników | encje domenowe, serwisy, obiekty ze złożonym zachowaniem |
Kiedy struct ma sens: mała, niemutowalna wartość reprezentująca “jedną liczbę pojęciową” — punkt, zakres dat, pieniądze, współrzędne. Mikrosoftowa rekomendacja to twarda granica ok. 16 bajtów — powyżej niej koszt kopiowania przy każdym przekazaniu zaczyna przeważać nad oszczędnością na alokacji.
Kiedy struct to pułapka: gdy jest mutowalny i trafia do kolekcji. Klasyczny błąd:
struct Licznik
{
public int Wartosc;
public void Zwieksz() => Wartosc++;
}
var liczniki = new List<Licznik> { new Licznik() };
liczniki[0].Zwieksz(); // BŁĄD KOMPILACJI w C# — i słusznie
Ten kod się nie skompiluje, bo liczniki[0] zwraca kopię struktury z listy, a wywołanie Zwieksz() na kopii, która natychmiast znika, nie miałoby sensu. Kompilator Cię tu ratuje. Gorzej, gdy struktura trafia do zmiennej pośredniej — wtedy kod się skompiluje, ale modyfikacja nigdy nie dotrze do oryginału w kolekcji. To dlatego dobra praktyka brzmi: struktury powinny być niemutowalne (readonly struct).
Boxing i unboxing — ukryty koszt konwersji
Boxing to proces “opakowania” wartości typu wartościowego w obiekt na stercie, żeby mogła być traktowana jak typ referencyjny — np. przypisana do zmiennej object albo dodana do nie-generycznej kolekcji. Unboxing to operacja odwrotna.
int liczba = 42; // typ wartościowy, na stosie
object opakowana = liczba; // BOXING — kopia trafia na stertę
int odzyskana = (int)opakowana; // UNBOXING — kopia wraca na stos
Wygląda niewinnie, ale w pętli albo w hot path aplikacji to realny koszt: alokacja na stercie (a więc i praca dla GC) za każdym razem, gdy boxing się wydarza. Klasyczny przykład produkcyjny — nieświadomy boxing przy użyciu nie-generycznych kolekcji:
using System.Collections;
ArrayList lista = new ArrayList();
for (int i = 0; i < 1_000_000; i++)
{
lista.Add(i); // boxing przy KAŻDYM dodaniu — 1 000 000 alokacji na stercie
}
Ten sam kod z List<int> nie robi boxingu ani razu, bo generyki w .NET są reified — dla typu wartościowego kompilator generuje wyspecjalizowaną implementację operującą bezpośrednio na int, bez przechodzenia przez object:
List<int> lista = new List<int>();
for (int i = 0; i < 1_000_000; i++)
{
lista.Add(i); // brak boxingu
}
To jeden z powodów, dla których w kodzie z 2024+ praktycznie nie zobaczysz ArrayList ani Hashtable — generyczne odpowiedniki są nie tylko bezpieczniejsze typowo, ale i wydajniejsze o rząd wielkości przy dużych zbiorach danych.
Mniej oczywiste miejsce, w którym boxing wkrada się po cichu: interpolacja stringów i logowanie.
int status = 200;
logger.LogInformation("Status: {Status}", status); // brak boxingu, params object[] ale zoptymalizowane
Console.WriteLine("Status: " + status); // konwersja przez ToString(), bez boxingu
object x = status; // to już jawny boxing
Warto sprawdzać sygnatury API, z których korzystasz — część starszych przeciążeń metod (np. niektóre warianty String.Format czy stare API logujące) przyjmuje object[], co oznacza boxing dla każdego argumentu typu wartościowego.
Częste pułapki w kodzie produkcyjnym
1. Mutowalny struct jako pole readonly. Nawet gdy pole klasy jest readonly, jeśli jego typem jest mutowalny struct, kompilator i tak przy każdym dostępie do metody na tym polu tworzy defensywną kopię — co bywa źródłem trudnych do zdiagnozowania różnic w zachowaniu między Debug a Release.
2. Porównywanie struktur przez ==. Wbudowana implementacja Equals() dla struct korzysta z refleksji i porównuje pola jeden po drugim — to wolne. Jeśli porównujesz strukturę często (np. jako klucz w słowniku), przesłoń Equals() i GetHashCode() ręcznie albo użyj record struct, gdzie kompilator wygeneruje to za Ciebie w sposób zoptymalizowany.
3. Przekazywanie dużych struktur przez wartość do metod. Struktura 200-bajtowa przekazywana jako argument metody wywoływanej milion razy w pętli to 200 MB skopiowanych danych. W takich przypadkach użyj modyfikatora in (parametr tylko do odczytu, przekazywany przez referencję bez ryzyka mutacji) zamiast kopiowania wartości.
readonly struct Wektor3D
{
public readonly double X, Y, Z;
public Wektor3D(double x, double y, double z) => (X, Y, Z) = (x, y, z);
}
double Dlugosc(in Wektor3D v) => Math.Sqrt(v.X * v.X + v.Y * v.Y + v.Z * v.Z);
Najczęstsze pytania
Czy string to typ wartościowy czy referencyjny? Referencyjny — string to class. To, że zachowuje się “jak wartość” (niemutowalność, porównanie przez == po zawartości), to efekt świadomego przesłonięcia operatorów i metod przez zespół .NET, a nie tego, że jest typem wartościowym.
Czy struct zawsze jest szybszy niż class? Nie. Jest szybszy w wąskim zakresie scenariuszy: małe, krótko żyjące wartości tworzone i niszczone bardzo często, gdzie unikasz presji na GC. Dla większych struktur kopiowanych wielokrotnie koszt kopiowania może przewyższyć zysk z braku alokacji na stercie.
Czy Nullable<int> (int?) to nadal typ wartościowy? Tak — Nullable<T> to sam w sobie struct opakowujący T oraz flagę HasValue. To zupełnie inny mechanizm niż Nullable Reference Types (string?), które są jedynie adnotacją dla kompilatora i analizatora statycznego, nie zmieniają layoutu w pamięci.
Jak sprawdzić w praktyce, czy dany typ trafia na stos czy stertę? Najpewniejszy sposób to podgląd w Visual Studio (Debug → Windows → Memory) albo napisanie mikrobenchmarku w BenchmarkDotNet z MemoryDiagnoser, który pokaże liczbę alokacji (Gen 0 collections, Allocated) dla porównywanych implementacji.
Podsumowanie
Typy wartościowe kopiują dane przy każdym przypisaniu i przekazaniu — żyją na stosie, o ile nie są opakowane w typ referencyjny. Typy referencyjne kopiują adres, więc wiele zmiennych może wskazywać na ten sam obiekt na stercie. Boxing to ukryty, kosztowny most między tymi światami — unikaj go w hot pathach, korzystając z generyków zamiast kolekcji nie-generycznych. Praktyczna zasada na produkcję: domyślnie używaj class; sięgaj po struct (najlepiej readonly struct) tylko dla małych, niemutowalnych wartości, i to świadomie, a nie “dla wydajności”, której nie zmierzyłeś.
🚀 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ę →