Wyselekcjonowany zestaw pytań rekrutacyjnych C# dla programistów backendowych. Każda odpowiedź zawiera uzasadnienie oczekiwane przez rekruterów, a wiele z nich zawiera krótkie przykłady kodu, które można omówić na głos.
1Czym różnią się typy wartościowe od typów referencyjnych?
Zmienne typów wartościowych zazwyczaj przechowują samą wartość, więc operacja przypisania powoduje jej skopiowanie. Zmienne typów referencyjnych przechowują referencję do obiektu, więc przypisanie kopiuje referencję, a obie zmienne mogą obserwować mutacje tego samego obiektu. Ważnym niuansem jest to, że miejsce przechowywania zależy od czasu życia (lifetime) i kontekstu, a nie tylko od samego typu: wartość lokalna metody może być przechowywana na stosie, podczas gdy typ wartościowy zapisany jako pole klasy, element tablicy, zmienna przechwycona w domknięciu (closure capture), wartość opakowana (boxed value) lub będąca polem maszyny stanu metody asynchronicznej (async), żyje wraz z powiązanym obiektem.
int a = 10;
int b = a;
b = 20;
// a is still 10
var first = new User { Name = "Ann" };
var second = first;
second.Name = "Kate";
// first.Name is also "Kate"
2Czym różnią się od siebie klasa (`class`), struktura (`struct`) i rekord (`record`) w C#?
Klasa to typ referencyjny z tożsamością obiektu. Struktura to typ wartościowy, który najlepiej sprawdza się dla małych, niezmiennych wartości, takich jak współrzędne, zakresy czy identyfikatory. Klasa typu rekordu (`record class`) to typ referencyjny z równością opartą na wartościach; rekord strukturalny (`record struct`) to jego odpowiednik jako typ wartościowy. Rekordy to składnia kompilatora nałożona na zwykłe typy CLR (Common Language Runtime), która generuje obsługę równości, metod `GetHashCode` i `ToString`, dekonstrukcji oraz wsparcie dla kopiowania, w tym dla wyrażeń `with` umożliwiających nieniszczącą mutację. W kodzie backendowym rekordy są użyteczne jako modele podobne do DTO (Data Transfer Object), podczas gdy duże, mutowalne struktury stanowią ryzyko, ponieważ kopiowanie i modyfikowanie kopii może być zaskoczeniem dla czytających kod.
public record User(int Id, string Name);
var first = new User(1, "Alice");
var second = new User(1, "Alice");
Console.WriteLine(first == second); // true
3Czym różnią się parametry `ref`, `out` i `in` w C#?
`ref` przekazuje argument przez referencję i wymaga, aby był on zainicjowany przed wywołaniem; metoda może go odczytywać i modyfikować. `out` również przekazuje parametr przez referencję, ale metoda musi przypisać mu wartość przed powrotem z wywołania. `in` przekazuje parametr jako referencję tylko do odczytu (`readonly byref`), co może zapobiegać kopiowaniu dużych struktur, ale nie gwarantuje głębokiej niezmienności: mutowalne struktury i członkowie niebędący `readonly` mogą wymuszać tworzenie kopii defensywnych. Na poziomie języka pośredniego IL (Intermediate Language) w .NET, `ref`, `out` oraz `in` to zarządzane parametry referencyjne podlegające różnym regułom języka C#.
void Increment(ref int value) => value++;
bool TryRead(string text, out int value) =>
int.TryParse(text, out value);
double Length(in Vector vector) =>
Math.Sqrt(vector.X * vector.X + vector.Y * vector.Y);
4Czym są pakowanie (boxing) i rozpakowywanie (unboxing)?
Pakowanie (boxing) opakowuje typ wartościowy w obiekt typu referencyjnego poprzez zaalokowanie obiektu i skopiowanie do niego wartości. Rozpakowywanie (unboxing) wyodrębnia wartość z powrotem i wymaga dokładnie takiego samego, kompatybilnego typu. Częste pakowanie powoduje dodatkowe alokacje i zwiększa presję na GC (Garbage Collector). Typy generyczne (generics) pozwalają uniknąć wielu przypadków pakowania, ponieważ `List<int>` przechowuje wartości `int` bez konwertowania każdej z nich na `object`.
int number = 42;
object boxed = number; // boxing
int restored = (int)boxed; // unboxing
object value = 42;
// long wrong = (long)value; // InvalidCastException
long ok = (long)(int)value;
5Czym są typy wartościowe dopuszczające wartość null (nullable value types) i typy referencyjne dopuszczające wartość null (nullable reference types)?
Typy wartościowe dopuszczające wartość null używają struktury `Nullable<T>` lub zapisu `T?` i stanowią rzeczywistą nakładkę (wrapper) na typy wartościowe w czasie działania programu. Typy referencyjne dopuszczające wartość null to w głównej mierze wynik analizy na poziomie kompilatora: zapis `string?` informuje kompilator, że referencja może mieć wartość null, ale nie tworzy nowego typu w czasie wykonania. Operator wybaczający null (null-forgiving operator) `!` służy jedynie do tłumienia ostrzeżeń; nie chroni natomiast przed wyjątkiem `NullReferenceException`.
int? age = null;
string name = "Alice";
string? optionalName = null;
var length = optionalName?.Length;
var actualName = optionalName ?? "Unknown";
optionalName ??= "Default";
string trusted = optionalName!;
6Czym różnią się modyfikatory const, readonly i static readonly?
Modyfikator const oznacza stałą czasu kompilacji (compile-time constant), ograniczoną do typów prostych (primitives), wartości enum, typu string lub wartości null, a jej wartość jest bezpośrednio wstawiana do korzystających z niej podzespołów (assemblies) podczas kompilacji. Modyfikator readonly to pole instancji, któremu można przypisać wartość podczas deklaracji lub w konstruktorze. Z kolei static readonly to jedno pole dla całego typu, do którego przypisanie następuje w deklaracji lub w konstruktorze statycznym. Publiczne wartości, które mogą ulec zmianie w nowych wersjach biblioteki, są bezpieczniejsze jako static readonly niż jako publiczne const, ponieważ kod klienta korzystający z biblioteki zatrzyma starą, wstawioną bezpośrednio (inlined) wartość const aż do czasu własnej rekompilacji.
public const int MaxAttempts = 3;
private readonly Guid _id = Guid.NewGuid();
public static readonly TimeSpan Timeout =
TimeSpan.FromSeconds(30);
7Jak współpracują ze sobą metody `Equals`, `==` oraz `GetHashCode`?
W przypadku zwykłych klas, `Equals` oraz `==` domyślnie porównują referencje. Wiele wbudowanych typów oraz typów `record` porównuje wartości. Jeśli metoda `Equals` jest przesłonięta, `GetHashCode` musi być z nią spójna: jeśli `a.Equals(b)` jest prawdą, oba obiekty muszą mieć ten sam kod mieszający (hash code). Sytuacja odwrotna nie jest gwarantowana. Nigdy nie należy modyfikować pól używanych do porównywania lub obliczania kodu mieszającego, gdy obiekt jest kluczem w `Dictionary` lub elementem `HashSet`.
public sealed class User : IEquatable<User>
{
public required int Id { get; init; }
public bool Equals(User? other) =>
other is not null && Id == other.Id;
public override bool Equals(object? obj) =>
obj is User other && Equals(other);
public override int GetHashCode() =>
Id.GetHashCode();
}
override zmienia wirtualne zachowanie, co oznacza, że wywoływana metoda zależy od typu obiektu w czasie działania programu (runtime). new ukrywa składową z klasy bazowej, a wybrana składowa zależy od statycznego typu zmiennej. Aby uzyskać polimorfizm, zazwyczaj stosuje się virtual i override; ukrywanie za pomocą new jest rzadsze i może prowadzić do niejasnego zachowania programu.
class Base
{
public virtual void First() => Console.WriteLine("Base.First");
public void Second() => Console.WriteLine("Base.Second");
}
class Derived : Base
{
public override void First() => Console.WriteLine("Derived.First");
public new void Second() => Console.WriteLine("Derived.Second");
}
Base value = new Derived();
value.First(); // Derived.First
value.Second(); // Base.Second
Klasa abstrakcyjna może zawierać stan, konstruktory, pola, zaimplementowane metody, metody abstrakcyjne oraz chronione składowe. Interfejs opisuje kontrakt; nowoczesne interfejsy mogą zawierać domyślne implementacje i statyczne składowe abstrakcyjne, ale nadal nie są one standardowym miejscem dla stanu instancji. Klasa może dziedziczyć po jednej klasie bazowej, ale implementować wiele interfejsów. Klasy abstrakcyjnej używaj jako wspólnej bazy i do współdzielonej implementacji; interfejsu używaj do definiowania możliwości i granic systemowych.
public interface IAsyncRepository<T>
{
Task<T?> FindAsync(
int id,
CancellationToken cancellationToken);
}
10Czym są typy generyczne (generics) i dlaczego są przydatne?
Typy generyczne pozwalają na pisanie kodu wielokrotnego użytku, który jest bezpieczny typologicznie. Przenoszą one wiele weryfikacji na etap kompilacji, eliminują konieczność ręcznego rzutowania typów i pozwalają uniknąć pakowania (boxing) w przypadku typów wartościowych. Ograniczenia typów generycznych określają, co dany parametr typu musi obsługiwać, np. czy musi być klasą, strukturą, implementacją interfejsu, klasą bazową, typem niezarządzanym (unmanaged type), czy posiadać publiczny konstruktor bezparametrowy.
public T? Find<T>(
IEnumerable<T> items,
Predicate<T> predicate)
{
return items.FirstOrDefault(item => predicate(item));
}
public T Create<T>() where T : class, new()
{
return new T();
}
Kowariancja pozwala kodowi użyć bardziej szczegółowego typu tam, gdzie oczekiwany jest bardziej ogólny typ produkowany (zwracany), na przykład IEnumerable<string> jako IEnumerable<object>. Jest oznaczana słowem kluczowym out w interfejsach i delegatach. Kontrawariancja działa w odwrotnym kierunku dla wartości konsumowanych (przyjmowanych jako argumenty) i jest oznaczana słowem kluczowym in. Wariancja działa tylko dla interfejsów i delegatów z typami referencyjnymi; IEnumerable<int> nie może stać się IEnumerable<object>, ponieważ wymagałoby to pakowania (boxing), a List<string> nie jest przypisywalna do List<object>.
IEnumerable<T> reprezentuje iterację w środowisku .NET, a operatory LINQ (Language Integrated Query) zazwyczaj przyjmują delegaty, takie jak Func<T, bool>. Z kolei IQueryable<T> przechowuje drzewa wyrażeń (expression trees), na przykład Expression<Func<T, bool>>, które dostawca, taki jak Entity Framework Core, może przetłumaczyć na zapytanie SQL (Structured Query Language). Zbyt wczesne wywołanie metody ToList materializuje dane i przenosi późniejsze filtrowanie do pamięci. Ponadto, nie każde wyrażenie w C# może zostać przetłumaczone przez dostawcę zapytań. Należy unikać przekazywania IQueryable przez wszystkie warstwy aplikacji, chyba że jest to celowa decyzja projektowa.
13Czym jest odroczone wykonanie (deferred execution) w LINQ?
Wiele operatorów LINQ (Language Integrated Query) buduje zapytanie bez jego natychmiastowego wykonania. Wykonanie rozpoczyna się, gdy zapytanie jest iterowane (enumerowane) lub gdy zostanie wywołana operacja końcowa, taka jak ToList, Count lub First. Oznacza to, że zmiany w źródle przed iteracją mogą wpłynąć na wyniki, wielokrotna iteracja może powodować powtarzanie pracy lub zapytań SQL (Structured Query Language), a wyjątki mogą występować podczas iteracji, a nie podczas budowania zapytania.
var query = numbers.Where(x => x > 10);
numbers.Add(42);
foreach (var number in query)
{
Console.WriteLine(number);
}
var materialized = query.ToList();
14Czym różnią się tablica (array), `List<T>`, `Dictionary<TKey,TValue>` i `HashSet<T>`?
Tablica (array) ma stały rozmiar i zapewnia szybki dostęp z użyciem indeksu. `List<T>` to tablica dynamiczna z dostępem indeksowanym o złożoności O(1); metoda `Add` dodająca element na końcu działa w zamortyzowanym czasie O(1), ponieważ zwiększanie pojemności powoduje alokację większej tablicy i kopiowanie istniejących elementów, podczas gdy wstawienie w środku zajmuje O(n). `Dictionary<TKey,TValue>` mapuje klucze na wartości i zazwyczaj zapewnia wyszukiwanie klucza w czasie O(1). `HashSet<T>` przechowuje unikalne elementy i jest idealny do sprawdzania przynależności. Dla kolekcji opartych na haszowaniu kluczowe są poprawne implementacje metod `Equals` i `GetHashCode`.
var ids = new HashSet<int> { 1, 2, 3 };
if (ids.Contains(userId))
{
// Fast membership check
}
15Czym różnią się interfejsy `IEnumerable<T>`, `ICollection<T>`, `IList<T>` oraz `IReadOnlyCollection<T>`?
Interfejs `IEnumerable<T>` gwarantuje jedynie możliwość sekwencyjnej iteracji. `ICollection<T>` dodaje właściwość `Count` oraz metody modyfikujące (np. dodawanie - `Add`, usuwanie - `Remove`). `IList<T>` udostępnia dodatkowo dostęp za pomocą indeksu. `IReadOnlyCollection<T>` umożliwia z kolei iterację i odczyt właściwości `Count`, ale nie pozwala na operacje modyfikujące z poziomu tego interfejsu. W przypadku projektowania API (Application Programming Interface), powinno się zwracać najwęższy użyteczny kontrakt. Warto jednak pamiętać, że samo stosowanie interfejsów tylko do odczytu (read-only) nie zapewnia głębokiej niemutowalności leżącego pod nimi obiektu.
Instrukcja yield return tworzy iterator z odroczonym wykonaniem (deferred execution). Kompilator przekształca metodę w maszynę stanów, a wartości są produkowane pojedynczo po wywołaniu metody MoveNext. Pozwala to uniknąć tworzenia kolekcji pośrednich i dobrze sprawdza się w przypadku dużych sekwencji. Pułapki związane są z czasem życia i wielokrotną enumeracją: nie należy zwracać leniwie ewaluowanej sekwencji (lazy sequence), która zależy od zlikwidowanego (disposed) obiektu DbContext lub strumienia. Należy również pamiętać, że wywołanie metody Count, a po niej pętli foreach, może wykonać iterator dwukrotnie, powtarzając efekty uboczne lub operacje I/O (Input/Output).
IEnumerable<int> GetEvenNumbers(IEnumerable<int> source)
{
foreach (var number in source)
{
if (number % 2 == 0)
yield return number;
}
}
Delegat (delegate) to bezpieczna typologicznie referencja do metody. Wyrażenia lambda dostarczają zwięzłe, wbudowane (inline) funkcje i są powszechnie przypisywane do delegatów typu Func, Action lub Predicate. Wyrażenia lambda przechwytują zmienne, a nie migawki (snapshots) ich wartości, więc lambda może zaobserwować późniejszą wartość pętli lub zmiennej. Jeśli lambda niczego nie przechwytuje, kompilator może zbuforować statyczny delegat i uniknąć alokacji domknięcia (closure); w kodzie wrażliwym na wydajność, domknięcia i wydłużony czas życia obiektów mogą mieć istotne znaczenie.
public delegate int Operation(int x, int y);
Operation operation = (x, y) => x + y;
Console.WriteLine(operation(2, 3));
int factor = 10;
Func<int, int> multiply = x => x * factor;
Zdarzenie (`event`) zazwyczaj opiera się na delegacie (`delegate`), ale ogranicza to, co może z nim zrobić kod z zewnątrz. Zewnętrzny kod może subskrybować i anulować subskrypcję, ale tylko klasa będąca właścicielem może wywołać to zdarzenie. Publiczne pole typu `delegate` pozwoliłoby kodowi wywołującemu na zastąpienie listy wywołań lub bezpośrednie jej wywołanie. Zdarzenia tworzą również referencje od publikującego do subskrybenta, więc długo żyjące obiekty publikujące mogą powodować wycieki pamięci, jeśli subskrybenci nie anulują subskrypcji.
Używaj wyjątków dla sytuacji wyjątkowych, a nie jako elementu normalnego przepływu sterowania. Przechwytuj tylko te wyjątki, z którymi potrafisz sobie poradzić, unikaj pustych bloków `catch`, dodawaj kontekst podczas ich opakowywania i nie ujawniaj użytkownikom wewnętrznych szczegółów. Używaj `throw;`, aby rzucić wyjątek ponownie przy zachowaniu oryginalnego śladu stosu. Jeśli opakowujesz wyjątek, przekaż oryginalny jako `InnerException`, na przykład `throw new DomainException("...", ex)`. Preferuj API w stylu `TryParse` dla oczekiwanych niepowodzeń.
Filtry wyjątków pozwalają na wykonanie bloku `catch` tylko wtedy, gdy dodatkowy warunek jest prawdziwy. Filtr jest oceniany przed rozwinięciem stosu (stack unwinding) i przed wejściem do ciała bloku `catch`, dzięki czemu w przypadku zwrócenia wartości false, oryginalny stos pozostaje nienaruszony, co ułatwia diagnostykę i analizę zrzutów awaryjnych (crash dumps). Filtry są przydatne przy obsłudze kodów statusu HTTP (Hypertext Transfer Protocol), kodów błędów bazy danych, rozróżnianiu błędów przejściowych i warunkowym logowaniu. Należy dbać o to, aby filtry były proste i unikać w nich złożonych efektów ubocznych.
try
{
await SendAsync();
}
catch (HttpRequestException ex)
when (ex.StatusCode == HttpStatusCode.NotFound)
{
// Handle only 404
}