🔥 Zapisy zamknięte, ale możesz pobrać Roadmapę .NET i dołączyć do listy oczekujących — Pobierz i dołącz do Listy VIP →

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, wszystkie struct (w tym DateTime, Guid, decimal) oraz enum.
  • 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 z System.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

Kryteriumstruct (value type)class (reference type)
Domyślna semantykakopiowanie wartościwspółdzielenie referencji
Alokacjastos (jeśli zmienna lokalna)zawsze sterta
Dziedziczeniebrak (implicit sealed)pełne wsparcie
Nullabilitynie może być null (poza Nullable<T>)może być null
Rozmiar w pamięcipowinien być mały (rekomendacja: ≤16 bajtów)dowolny
Typowe zastosowanieDateTime, Guid, współrzędne, pieniądze, klucze do słownikówencje 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ś.

👨‍💻
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ę →