Wszystkie wpisy

ref struct w C# - własne typy stack-only, mechanika zakazów CLR

Mit struct=stos rozbity, ref struct jako żelazna obietnica nieopuszczania stosu, tworzenie własnych typów stack-only (parsery, tokenizery), pełna lista zakazów CLR z mechaniką (async, pole klasy, iteratory, lambdy, boxing). Dziewiętnasta część kursu C#.


W poprzednim wpisie Memory i ReadOnlyMemory w C# zobaczyłeś, jak Memory<T> “przeżywa await”, podczas gdy Span<T> jest zabroniony w async. Tym, co napędza tę różnicę, jest słowo kluczowe ref przed deklaracją struktury. Span to ref struct - specjalna kategoria typów z żelaznym zakazem trafiania na stertę.

W tej części zobaczysz, jak tworzyć własne typy ref struct, dlaczego CLR nakłada na nie tak surowe restrykcje i jaką wspólną przyczynę mają wszystkie cztery zakazy (async, pole klasy, iteratory, lambdy). Po lekturze wpisu rozumiesz nie tylko, co jest zakazane, ale dlaczego - co pozwala diagnozować dziwne błędy kompilacji we własnych eksperymentach z wysokowydajnym kodem.

1. Mit “struct = stos” #

Najczęstszy mit początkujących C#: typy wartościowe zawsze żyją na stosie, typy referencyjne zawsze na stercie. To nieprawda.

Zwykły struct trafia na stertę w wielu sytuacjach:

SytuacjaGdzie żyje struct
Lokalna zmienna w metodzieStos
Pole klasySterta (z klasą)
Zboxowany do object lub interfejsuSterta (jako boxowany obiekt)
Pole w innym strukcie, który jest na stosieStos
Pole w innym strukcie, który jest polem klasySterta (transitively)
Element tablicy (MyStruct[])Sterta (tablica jest na stercie)
Pole w obiekcie iteratora (yield return)Sterta (auto-generated klasa)
Pole w maszynie stanów async po awaitSterta (boxed state machine)

Domyślnie struct jest alokowany w miejscu, w którym jest deklarowany - bywa to stos, bywa sterta. Modyfikator ref przed struct zmienia tę regułę w gwarancję: ten struct nigdy nie trafia na stertę.

2. Co obiecuje ref struct #

Kiedy piszesz przed deklaracją słowo ref, składasz kompilatorowi obietnicę:

“Ta struktura jest tak blisko sprzętu (pewnie trzyma wskaźniki do pamięci, jak Span<T>), że gwarantuję, iż nigdy nie opuści szybkiego stosu i nie trafi na stertę”.

Kompilator w zamian:

  • Pozwala strukturze trzymać niskopoziomowe wskaźniki do pamięci (ref T)
  • Eliminuje narzut alokacji - struktura żyje na stosie w pełnym znaczeniu tego słowa
  • Wymusza kontrakt przez błędy kompilacji we wszystkich miejscach, gdzie struktura mogłaby przeciec na stertę

Klasyczne przykłady ref struct w BCL:

TypPo co ref struct
Span<T>Trzyma ref do wnętrza tablicy / stackalloc / unmanaged memory
ReadOnlySpan<T>jw, read-only
Span<T>.EnumeratorIterator po Span, trzyma ref do bufora
Utf8JsonReaderWysokowydajny parser JSON, trzyma ReadOnlySpan<byte> + stan parsowania
SequenceReader<T>Reader po ReadOnlySequence<T>
System.Threading.Lock (C# 13+)Nowoczesna alternatywa dla lock(obj)

Wszystkie te typy mają wspólną cechę: trzymają referencję do pamięci, której CLR nie może bezpiecznie śledzić, gdyby trafiła na stertę.

3. Tworzenie własnego ref struct #

Po co miałbyś tworzyć własny ref struct? Najczęściej, gdy chcesz zgrupować kilka Span<T>/ReadOnlySpan<T> w jedną logiczną całość - np. parser, tokenizer, reader bufora. Dzięki temu, że Twoja struktura sama jest ref struct, może bezpiecznie trzymać Span’y jako pola.

public ref struct LogLineParser
{
    private ReadOnlySpan<char> _line;
    private int _position;

    public LogLineParser(ReadOnlySpan<char> line)
    {
        _line = line;
        _position = 0;
    }

    public bool TryReadField(out ReadOnlySpan<char> field)
    {
        if (_position >= _line.Length)
        {
            field = default;
            return false;
        }

        int start = _position;
        while (_position < _line.Length && _line[_position] != '|')
            _position++;

        field = _line.Slice(start, _position - start);

        if (_position < _line.Length)
            _position++;   // pomiń separator '|'

        return true;
    }
}

Użycie:

string log = "2026-06-18|INFO|Application started";
ReadOnlySpan<char> line = log.AsSpan();

LogLineParser parser = new LogLineParser(line);

while (parser.TryReadField(out ReadOnlySpan<char> field))
{
    Console.WriteLine(field.ToString());
}

Zero alokacji. Parser żyje na stosie, każdy field to wycinek oryginalnego bufora. Dla parsowania miliona linii logów - milion mniej obiektów dla GC.

4. Cztery zakazy CLR (i ich wspólna przyczyna) #

Wszystkie cztery zakazy mają jedną fundamentalną przyczynę: w każdej z tych sytuacji kompilator musiałby przenieść lokalne zmienne (w tym potencjalnie ref struct) na stertę.

Zakaz 1: Metody async #

public async Task Process(LogLineParser parser)   // ❌ BŁĄD KOMPILACJI
{
    await Task.Delay(100);
    parser.TryReadField(out _);
}

Mechanika: kompilator zamienia metodę async na maszynę stanów (klasę albo strukturę boxowaną przy await). Lokalne zmienne stają się polami tej maszyny. ref struct jako pole → sterta → sprzeczność.

Zakaz 2: Pole klasy lub zwykłej struktury #

public class Container
{
    public LogLineParser parser;   // ❌ BŁĄD KOMPILACJI - klasa żyje na stercie
}

public struct DataHolder
{
    public LogLineParser parser;   // ❌ BŁĄD - struct mógłby być polem klasy → sterta
}

Mechanika: klasa = sterta. Pole klasy = część obiektu = sterta. Drugi przypadek (struct w struct) jest zabroniony “na zapas” - zwykły struct może trafić na stertę (np. jako pole klasy), więc transitively ref struct w nim też.

Zakaz 3: Iteratory (yield return) #

public IEnumerable<int> Process()
{
    LogLineParser parser = ...;   // ❌ BŁĄD KOMPILACJI
    yield return 1;
}

Mechanika: yield return to lukier składniowy - kompilator generuje klasę implementującą IEnumerator<T>. Lokalne zmienne stają się polami tej klasy. Klasa = sterta. Identyczna sytuacja jak z async.

Zakaz 4: Przechwytywanie przez lambdy / funkcje lokalne #

public void Process()
{
    LogLineParser parser = ...;

    Action callback = () => parser.TryReadField(out _);   // ❌ BŁĄD KOMPILACJI
    callback();
}

Mechanika: lambda przechwytująca zmienną tworzy klasę-domknięcie (closure). Przechwycona zmienna staje się polem tej klasy. Klasa = sterta. Trzeci wariant tej samej historii.

Wzorzec wspólny #

Wszystkie cztery zakazy ma jedna fraza: “kompilator chce przenieść Twoje zmienne lokalne do auto-generowanej klasy”. To dzieje się w czterech miejscach:

  • async - generuje maszynę stanów (klasę lub boxowaną strukturę)
  • iteratory - generują klasę enumeratora
  • lambdy / lokalne funkcje z capture - generują klasę domknięcia
  • pole klasy - i tak jesteśmy w klasie

Gdy zrozumiesz wspólną przyczynę, nie musisz pamiętać czterech osobnych reguł. Wystarczy pytanie: czy w tym miejscu zmienna może wylądować w polu klasy? Jeśli tak - ref struct jest zabroniony.

5. Boxing - zabronione absolutnie #

Boxing to mechanizm CLR pozwalający typowi wartościowemu udawać object lub interfejs:

int number = 5;
object box = number;   // BOXING - tworzy nowy obiekt na stercie z kopią '5'

Dla ref struct boxing jest absolutnie zabroniony:

LogLineParser parser = ...;

object badIdea = parser;        // ❌ BŁĄD KOMPILACJI: Cannot box ref struct

Powód: boxing tworzy obiekt na stercie zawierający kopię struktury. ref struct nie może być na stercie. Kompilator wykrywa próbę statycznie - nie ma runtime error, błąd przy kompilacji.

Boxing przez interfejs #

Implementacja interfejsu też wymaga boxingu (gdy używasz interfejsu jako typu):

public interface IDisposable { void Dispose(); }

public ref struct MyParser : IDisposable    // historycznie ❌ BŁĄD
{
    public void Dispose() { }
}

// Powód: gdyby implementacja była dozwolona...
MyParser p = new();
IDisposable d = p;   // boxing przez rzutowanie na interfejs - sterta!

Historycznie ref struct w ogóle nie mógł deklarować implementacji interfejsów. Od C# 13 / .NET 9 sytuacja się zmieniła:

  • ref struct może implementować interfejs
  • Ale nadal nie można zboxować go przez rzutowanie na nazwę interfejsu
  • Wymaga to dodatkowo zmian w generykach przez allows ref struct anti-constraint

To zaawansowane - w codziennym kodzie wystarczy wiedzieć, że ref struct “praktycznie nie implementuje interfejsów”. Jeśli kiedyś trafisz na kompilator narzekający na boxing przy interfejsie, znasz przyczynę.

6. allows ref struct - C# 13+ uwalnia generyki #

Klasyczne generyki C# nie mogły przyjmować ref struct jako argumentu:

public void Process<T>(T value) { /* ... */ }

Process<Span<int>>(...);   // historycznie ❌ - Span<int> to ref struct

Powód: wewnątrz Process<T> kompilator mógłby teoretycznie zboxować T (np. wywołać value.ToString() przy debug). ref struct nie może być zboxowany - więc nie może być argumentem generycznym.

Od C# 13 / .NET 9 istnieje nowy anti-constraint:

public void Process<T>(T value) where T : allows ref struct
{
    // Kompilator wymusza, że NIC tu nie zboxuje T
    // Dzięki temu można przekazać Span<int> jako T
}

Process<Span<int>>(...);   // ✅ - z allows ref struct dozwolone

To znacząca poprawa dla bibliotek wysokowydajnych - można pisać kod generyczny działający zarówno dla zwykłych typów, jak i dla Span’ów.

7. Kiedy w praktyce piszesz własne ref struct? #

Bardzo rzadko w codziennym kodzie biznesowym. Listy:

SytuacjaCzy własny ref struct?
Aplikacja CRUD, e-commerce, dashboardyNie - zwykłe klasy, prosty kod
Mikroserwis HTTP obsługujący setki req/sMoże wartością Span dla parsing query string, ale rzadko własny ref struct
Logika biznesowa, integracje z baząNie - skup się na korektności
Parser pliku CSV/JSON/binarnego w hot pathTak - klasyczny przypadek
Serializator/deserializator danych binarnychTak - wysoka wydajność krytyczna
Engine bazy danych albo cache w pamięciTak - rdzeń systemu
Pisanie własnego JSON parseraTak - patrz Utf8JsonReader w BCL

Najważniejsza wiedza na Twoim etapie: rozumieć, dlaczego nie możesz wrzucić Span do async metody. Nie chodzi o kolejną regułę do zapamiętania, tylko o mechanikę CLR. Gdy rozumiesz, że async pakuje zmienne na stertę, a ref struct ma absolutny zakaz trafiania na stertę - cztery pozornie różne zakazy stają się jednym logicznym wnioskiem.

Podsumowanie tematu #

W tej części kursu poznałeś:

  • Mit “struct = stos” - zwykły struct ląduje na stercie w wielu sytuacjach (pole klasy, boxing, element tablicy, pole iteratora/async)
  • ref struct - żelazna obietnica dla kompilatora: nigdy na stertę; wymuszone przez błędy kompilacji
  • Po co własny ref struct - grupowanie wielu Span’ów w logiczną całość (parsery, tokenizery, readery); zachowanie zera alokacji
  • Klasyczne ref struct w BCL - Span<T>, ReadOnlySpan<T>, Utf8JsonReader, SequenceReader<T>, System.Threading.Lock
  • Cztery zakazy - async, pole klasy, iteratory, lambdy z capture
  • Wspólna przyczyna - we wszystkich czterech sytuacjach kompilator chce przenieść zmienne lokalne do auto-generowanej klasy; klasa = sterta; sprzeczność
  • Boxing zabroniony - rzutowanie na object lub interfejs to alokacja na stercie; kompilator odrzuca statycznie
  • Interfejsy - historycznie zabronione; od C# 13 / .NET 9 ograniczone wsparcie przez allows ref struct
  • allows ref struct anti-constraint (C# 13+) - pozwala generykom przyjmować Span’y bez ryzyka boxingu
  • Kiedy w praktyce - rzadko; biblioteki, parsery hot path, rdzeń systemów; w typowym kodzie biznesowym to wyjątek

W następnym wpisie Parsowanie tekstu bez alokacji w C# zastosujemy całą wiedzę z tygodnia w jednym kanonicznym scenariuszu produkcyjnym: zero-allocation log parser czytający miliony linii bez ani jednego zbędnego obiektu na stercie. Pięć narzędzi Span’a (AsSpan, IndexOf, Slice, SequenceEqual, StartsWith), benchmark wydajności i pułapki produkcyjne.

Quiz i zadanie poniżej. Zadanie demonstruje kanoniczny scenariusz ref struct - własny tokenizer trzymający ReadOnlySpan<char> jako pole. Dwa stack-only typy współpracują, dając parser z literalnie zerem alokacji na stercie.

Podobne wpisy

🔗 Linkują tu