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

Parametry ref, out, in i params w C#

W poprzednim artykule “Funkcje (metody) w C#” poznałeś podstawową składnię metod. Ale domyślne przekazywanie argumentów „przez wartość” to tylko połowa historii. C# ma cztery modyfikatory parametrów, które zmieniają tę regułę: ref, out, in i params. Każdy z nich rozwiązuje inny problem, każdy ma inne konsekwencje wydajnościowe i każdy pojawia się regularnie na rozmowach kwalifikacyjnych Junior .NET Developer — zwykle w formie pytania “jaka jest różnica między ref a out?”, na które większość kandydatów odpowiada niepełnie.

Ten artykuł domyka temat metod z poprzedniego wpisu i przygotowuje Cię pod kątem tego, co zobaczysz w API frameworka (int.TryParse, Dictionary.TryGetValue, Console.WriteLine z params object[]) już w kolejnych etapach ścieżki.

Punkt wyjścia: C# domyślnie przekazuje przez wartość

Zanim przejdziemy do modyfikatorów, jedno zdanie porządkujące: w C# domyślnie każdy argument jest kopiowany do parametru metody. Dla typów wartościowych (int, double, struct) kopiowana jest cała wartość. Dla typów referencyjnych (class, string, List<T>) kopiowana jest referencja — czyli adres obiektu, a nie sam obiekt. To dlatego modyfikacja właściwości obiektu przekazanego bez modyfikatora jest widoczna na zewnątrz metody, ale podmiana samej referencji (param = new Foo()) już nie:

void Zmien(Osoba osoba)
{
    osoba.Imie = "Zmienione";     // widoczne na zewnątrz — ta sama referencja
    osoba = new Osoba();           // niewidoczne na zewnątrz — kopia referencji
}

Modyfikatory ref, out i in zmieniają dokładnie ten mechanizm: zamiast kopiować wartość lub referencję, przekazują referencję do zmiennej wywołującego. To pozwala metodzie czytać i/lub nadpisywać oryginalną zmienną, a nie jej kopię.

ref — przekazywanie przez referencję w obie strony

ref mówi kompilatorowi: “nie kopiuj wartości, daj mi bezpośredni dostęp do zmiennej wywołującego”. Metoda może odczytać wartość wejściową i ją nadpisać.

void Podwoj(ref int liczba)
{
    liczba *= 2;
}

int x = 10;
Podwoj(ref x);
Console.WriteLine(x); // 20

Trzy zasady, o których warto pamiętać:

  • Zmienna musi być zainicjalizowana przed wywołaniem. ref zakłada, że wartość wejściowa jest sensowna — kompilator odmówi skompilowania wywołania z niezainicjalizowaną zmienną.
  • Słowo ref jest wymagane zarówno w deklaracji, jak i przy wywołaniu. To celowa decyzja projektantów C# — w miejscu wywołania od razu widzisz, że argument może zostać zmodyfikowany, bez zaglądania do sygnatury metody.
  • Struct przekazany przez ref nie jest kopiowany. To realna optymalizacja przy dużych strukturach (np. Matrix4x4 w grafice czy fizyce) — unikasz kopiowania kilkudziesięciu bajtów przy każdym wywołaniu.

Klasyczny przykład edukacyjny to Swap<T>:

void Swap<T>(ref T a, ref T b)
{
    (a, b) = (b, a);
}

out — kiedy metoda ma tylko coś zwrócić, nie odczytać

out to węższy wariant ref: zmienna nie musi być zainicjalizowana przed wywołaniem, ale metoda musi przypisać jej wartość przed zakończeniem — na każdej możliwej ścieżce wykonania, włącznie z gałęziami warunkowymi. Kompilator to wymusza.

To dokładnie mechanizm stojący za wzorcem TryParse / TryGetValue, który spotkasz w całym .NET:

bool TrySplitFullName(string fullName, out string firstName, out string lastName)
{
    var parts = fullName.Split(' ');
    if (parts.Length != 2)
    {
        firstName = string.Empty;
        lastName = string.Empty;
        return false;
    }

    firstName = parts[0];
    lastName = parts[1];
    return true;
}

if (TrySplitFullName("Jan Kowalski", out var imie, out var nazwisko))
{
    Console.WriteLine($"{imie} / {nazwisko}");
}

Zwróć uwagę na out var imie przy wywołaniu — to inline out variable declaration, dostępne od C# 7. Zamiast deklarować zmienną linijkę wcześniej, deklarujesz ją w miejscu wywołania. To dziś standard w kodzie produkcyjnym; starszy zapis z osobną deklaracją zobaczysz głównie w legacy code.

Dlaczego TryParse istnieje zamiast rzucać wyjątek? Bo parsowanie nieprawidłowego wejścia użytkownika to oczekiwany, a nie wyjątkowy scenariusz. Wyjątki w .NET są kosztowne (stack trace, unwinding), więc dla ścieżek, które regularnie kończą się niepowodzeniem — jak walidacja inputu — wzorzec Try* z out bool jest wydajniejszy niż int.Parse opakowany w try/catch. To samo w sobie jest częstym pytaniem rekrutacyjnym.

in — read-only referencja, czyli optymalizacja bez ryzyka

in, wprowadzony w C# 7.2, to modyfikator, który najczęściej jest pomijany na etapie nauki, a najczęściej pojawia się w kodzie zorientowanym na wydajność. Przekazuje zmienną przez referencję tylko do odczytu — metoda dostaje bezpośredni dostęp do oryginalnej zmiennej (bez kopiowania), ale kompilator nie pozwoli jej zmienić.

readonly struct Wektor3
{
    public readonly double X, Y, Z;
    public Wektor3(double x, double y, double z) => (X, Y, Z) = (x, y, z);
}

double Dlugosc(in Wektor3 v)
{
    // v.X = 5; // błąd kompilacji — in jest tylko do odczytu
    return Math.Sqrt(v.X * v.X + v.Y * v.Y + v.Z * v.Z);
}

Kiedy to ma znaczenie? Dla dużych struktur wartościowych przekazywanych milion razy na sekundę (silniki gier, obliczenia numeryczne, hot path w API o wysokim throughput). Dla int czy double różnica jest pomijalna — kopiowanie 4-8 bajtów jest tańsze niż przekazanie referencji. in zyskuje sens przy strukturach większych niż wskaźnik (czyli w praktyce powyżej ~16 bajtów na typowej platformie 64-bitowej).

Pułapka: in bez readonly struct może być wolniejsze, nie szybsze

To jeden z ulubionych “haczyków” na rozmowach z doświadczonymi developerami. Jeśli struct przekazany przez in nie jest oznaczony jako readonly, kompilator nie ma gwarancji, że jego metody nie zmodyfikują stanu wewnętrznie. Żeby zachować semantykę “tylko do odczytu” bez ryzyka mutacji przez wywołanie metody na v, kompilator potajemnie tworzy defensywną kopię struktury przy każdym wywołaniu jej metody. Efekt: dostajesz koszt kopiowania, którego in miało uniknąć, plus narzut samej referencji. Zasada praktyczna: in ma sens tylko dla readonly struct.

Jeśli parametr referencyjny ma wartość null, warto sprawdzić to przed użyciem — zobacz NullReferenceException w C# — jak naprawić?.

params — zmienna liczba argumentów

params pozwala wywołać metodę ze zmienną liczbą argumentów tego samego typu, bez ręcznego budowania tablicy:

int Suma(params int[] liczby)
{
    int wynik = 0;
    foreach (var l in liczby) wynik += l;
    return wynik;
}

Suma(1, 2, 3);          // kompilator pakuje argumenty w tablicę
Suma(new[] { 1, 2, 3 }); // albo przekazujesz tablicę bezpośrednio
Suma();                  // pusta tablica, nie null

To dokładnie mechanizm, na którym opiera się Console.WriteLine(string format, params object[] args) czy string.Join.

Trzy zasady składniowe:

  • params musi być ostatnim parametrem w sygnaturze.
  • W sygnaturze może wystąpić tylko jeden parametr params.
  • Jeśli nie przekażesz żadnego argumentu, dostajesz pustą tablicę — nie null — więc nie musisz sprawdzać null przed iteracją (choć nic nie stoi na przeszkodzie, by ktoś jawnie przekazał null).

Nowość: params z kolekcjami zamiast tylko tablic

Od C# 13 params nie jest już ograniczony do tablic — możesz użyć go z Span<T>, List<T> czy dowolnym typem implementującym odpowiedni wzorzec kolekcji, co pozwala uniknąć alokacji tablicy tam, gdzie wystarczy Span<T> na stosie. Jeśli piszesz kod pod .NET 9+, warto o tym wiedzieć — ale dla zdecydowanej większości zastosowań params int[] czy params string[] w zupełności wystarczy.

Tabela porównawcza

ModyfikatorInicjalizacja przed wywołaniemMusi być przypisany w metodzieKierunek danychTypowy przypadek użycia
refWymaganaNie (ale zwykle tak)DwukierunkowyModyfikacja istniejącej zmiennej, Swap, duże structy
outNiewymaganaWymagane (każda ścieżka)Tylko wyjścieWzorzec TryParse / TryGetValue, wiele wartości zwrotnych
inWymaganaZabronione (tylko odczyt)Tylko wejścieDuże readonly struct w hot path, unikanie kopiowania
paramsWejście, zmienna liczbaConsole.WriteLine, metody agregujące, fluent API

Pułapki, które naprawdę pojawiają się na produkcji i na rozmowach

1. ref/out nie działają z metodami async. Kompilator nie pozwoli oznaczyć parametru jako ref lub out w metodzie async. Powód jest strukturalny: metoda async może zwrócić kontrolę do wywołującego (przy pierwszym await) zanim się zakończy, a stan lokalny — w tym referencje ref/out do zmiennych ze stosu wywołującego — mógłby w tym czasie przestać być poprawny. Jeśli potrzebujesz “wielu wartości zwrotnych” z metody asynchronicznej, rozwiązaniem jest Tuple, record albo dedykowany typ wyniku — nie out.

2. To samo ograniczenie dotyczy iteratorów (yield return). Metoda z yield return jest kompilowana do maszyny stanów wykonywanej leniwie, kawałek po kawałku — z tego samego powodu ref/out w jej sygnaturze nie mają sensu i kompilator to zablokuje.

3. Nie można przeciążyć metody wyłącznie modyfikatorem ref/out. void Metoda(int x) i void Metoda(out int x) to konflikt — kompilator zgłosi błąd CS0663, bo na poziomie sygnatury binarnej (bez wartości argumentu w miejscu wywołania) te przeciążenia są nierozróżnialne dla części scenariuszy wiązania. Jeśli chcesz mieć wariant z out i bez, nadaj metodom różne nazwy (Parse / TryParse to właśnie ten wzorzec).

4. ref struct (np. Span<T>) ma własny zestaw ograniczeń. To osobny temat, ale warto zapamiętać hasło: Span<T> nie może trafić do pola klasy ani zostać przechwycony przez lambdę/async, właśnie dlatego że żyje na stosie. Rozwiniemy to w artykule o Span<T> w dalszej części ścieżki.

Dlaczego to pytanie rekrutacyjne

“Czym różni się ref od out?” to pytanie filtrujące — dobra odpowiedź w 15 sekund pokazuje, że kandydat rozumie różnicę między przekazywaniem przez referencję a zwracaniem wielu wartości, a nie tylko zapamiętał dwa słowa kluczowe. Pełna odpowiedź powinna zawierać: wymóg inicjalizacji przed wywołaniem (ref tak, out nie), wymóg przypisania w metodzie (out tak, zawsze), oraz typowy przypadek użycia (TryParse). Jeśli dorzucisz in i pułapkę defensywnej kopii przy nie-readonly struct, wychodzisz poza poziom Junior. Kompletny zestaw pytań rekrutacyjnych z gotowymi odpowiedziami znajdziesz w 50 pytań rekrutacyjnych Junior .NET Developer.

Zanim przejdziesz do ref, out i in, upewnij się, że rozumiesz podstawy deklaracji i wywoływania metod — zobacz Metody w C# — składnia, parametry, przeciążanie.

FAQ

Czy mogę użyć ref i out jednocześnie z params? params musi być ostatnim parametrem, więc ref/out mogą wystąpić przed nim w tej samej sygnaturze — ale nie na parametrze oznaczonym params. Sam parametr params nie może mieć jednocześnie modyfikatora ref ani out.

Czy in daje realny zysk wydajnościowy dla int czy double? Nie — dla typów prostych in jest neutralne albo lekko szkodliwe (referencja to zwykle 8 bajtów na platformie 64-bitowej, czyli tyle samo albo więcej niż sam int). Sens ma dopiero dla większych readonly struct.

Czy out może mieć wartość domyślną jak zwykły parametr opcjonalny? Nie. Parametry out nie mogą mieć wartości domyślnych — wywołujący zawsze musi jawnie przekazać zmienną (choćby out _, jeśli wynik go nie interesuje).

Podsumowanie

  • Domyślnie C# przekazuje argumenty przez wartość (dla typów referencyjnych — kopiuje referencję, nie obiekt).
  • ref — dwukierunkowa referencja, zmienna musi być zainicjalizowana przed wywołaniem.
  • out — tylko wyjście, metoda musi przypisać wartość na każdej ścieżce; fundament wzorca TryParse.
  • in — referencja tylko do odczytu, sens ma wyłącznie dla dużych readonly struct; bez readonly grozi defensywną kopią.
  • params — zmienna liczba argumentów, musi być ostatnim parametrem, tylko jeden na sygnaturę.
  • ref/out nie działają w metodach async ani w iteratorach z yield return — to najczęstsza pułapka produkcyjna.

👨‍💻
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.

1 comment

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ę →