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:
| Sytuacja | Gdzie żyje struct |
|---|---|
| Lokalna zmienna w metodzie | Stos |
| Pole klasy | Sterta (z klasą) |
Zboxowany do object lub interfejsu | Sterta (jako boxowany obiekt) |
| Pole w innym strukcie, który jest na stosie | Stos |
| Pole w innym strukcie, który jest polem klasy | Sterta (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 await | Sterta (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:
| Typ | Po co ref struct |
|---|---|
Span<T> | Trzyma ref do wnętrza tablicy / stackalloc / unmanaged memory |
ReadOnlySpan<T> | jw, read-only |
Span<T>.Enumerator | Iterator po Span, trzyma ref do bufora |
Utf8JsonReader | Wysokowydajny 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 structmoże implementować interfejs- Ale nadal nie można zboxować go przez rzutowanie na nazwę interfejsu
- Wymaga to dodatkowo zmian w generykach przez
allows ref structanti-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:
| Sytuacja | Czy własny ref struct? |
|---|---|
| Aplikacja CRUD, e-commerce, dashboardy | Nie - zwykłe klasy, prosty kod |
| Mikroserwis HTTP obsługujący setki req/s | Moż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 path | Tak - klasyczny przypadek |
| Serializator/deserializator danych binarnych | Tak - wysoka wydajność krytyczna |
| Engine bazy danych albo cache w pamięci | Tak - rdzeń systemu |
| Pisanie własnego JSON parsera | Tak - 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
structlą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 structw 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
objectlub interfejs to alokacja na stercie; kompilator odrzuca statycznie - Interfejsy - historycznie zabronione; od C# 13 / .NET 9 ograniczone wsparcie przez
allows ref struct allows ref structanti-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
Memory i ReadOnlyMemory w C# - asynchroniczny krewniak Spana
Memory<T> jako obietnica Span<T> przeżywająca await, ReadAsync z buforem, ReadOnlyMemory<char> dla tekstu w async, IMemoryOwner i MemoryPool dla recyklingu, lifetime rules - reguły 3-8 z dokumentacji MS o czasie życia bufora. Osiemnasta część kursu C#.
Span i ReadOnlySpan w C# - okna na pamięć bez alokacji
Eliminacja alokacji w pętlach przetwarzania - Span jako widok na ciągły obszar pamięci, ReadOnlySpan do parsowania tekstu bez Substring, ref struct i jego ograniczenia, stackalloc, ArrayPool, Memory dla async. Siedemnasta część kursu C#.
ArrayPool i MemoryPool w C# - wypożyczalnia tablic kontra LOH
Recykling dużych buforów dla hot path - LOH (Large Object Heap) i jego pułapki, ArrayPool<T>.Shared z Rent/Return, pułapka nadmiarowego rozmiaru, MemoryPool<T> z IMemoryOwner i using dla async. Dwudziesta pierwsza część kursu C#.
🔗 Linkują tu