Підбірка питань для співбесіди з C# для backend-розробників. Кожна відповідь містить пояснення, яке очікують почути інтерв'юери, та приклади коду, які зручно обговорювати вголос.
1Чим типи-значення відрізняються від посилальних типів?
Змінні типів-значень зазвичай містять саме значення, тому присвоєння копіює значення. Змінні посилальних типів містять посилання на об’єкт, тому присвоєння копіює посилання, і обидві змінні можуть спостерігати зміни одного й того самого об’єкта. Важливий нюанс: місце зберігання залежить від часу життя та контексту, а не лише від типу: значення, локальне для методу, може розміщуватися в стеку, тоді як тип-значення, збережений як поле класу, елемент масиву, значення, захоплене замиканням, упаковане значення (boxed value) або поле машини станів (state machine) асинхронного методу, живе разом із цим об’єктом.
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"
class — це тип за посиланням з ідентичністю об'єкта. struct — це тип за значенням і найкраще підходить для невеликих незмінних значень, наприклад координат, діапазонів або ідентифікаторів. record class — це тип за посиланням з рівністю на основі значення; record struct — версія типу за значенням. record — це синтаксис компілятора над звичайними типами CLR (Common Language Runtime), який генерує рівність, GetHashCode, ToString, деконструкцію та підтримку копіювання, зокрема with-вирази для неруйнівної зміни. У бекенд-коді record зручні для моделей на кшталт DTO (Data Transfer Object), тоді як великі змінювані struct ризиковані, бо копіювання та зміна копій можуть здивувати читачів коду.
public record User(int Id, string Name);
var first = new User(1, "Alice");
var second = new User(1, "Alice");
Console.WriteLine(first == second); // true
ref передає аргумент за посиланням і вимагає, щоб його було ініціалізовано до виклику; метод може його читати й змінювати. out також передає за посиланням, але метод зобов'язаний присвоїти значення до повернення. in передає byref лише для читання, що може уникнути копіювання великих struct, однак це не гарантія глибокої незмінності: змінні struct і члени, що не є readonly, можуть змушувати робити захисні копії. На рівні IL параметри ref, out і in — це керовані byref-параметри з різними правилами 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);
4Що таке пакування (boxing) та розпакування (unboxing)?
Пакування (boxing) обгортає тип значення (value type) в об'єкт типу посилання (reference type) шляхом виділення пам'яті для об'єкта та копіювання значення в нього. Розпакування (unboxing) витягує значення назад і вимагає точного сумісного типу. Часте пакування додає виділення пам'яті та збільшує навантаження на збирач сміття (Garbage Collector). Узагальнення (generics) уникають багатьох випадків пакування, оскільки `List<int>` зберігає значення `int` без перетворення кожного значення на `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;
5Що таке типи значень, які допускають null (nullable value types), та посилальні типи, які допускають null (nullable reference types)?
Типи значень, які допускають null, використовують Nullable<T> або T? і є реальною обгорткою для типів значень під час виконання. Посилальні типи, які допускають null, здебільшого є інструментом аналізу для компілятора: string? повідомляє компілятору, що посилання може дорівнювати null, але не створює нового типу під час виконання. Оператор прощення null (null-forgiving operator) ! лише пригнічує попередження; він не захищає від NullReferenceException.
int? age = null;
string name = "Alice";
string? optionalName = null;
var length = optionalName?.Length;
var actualName = optionalName ?? "Unknown";
optionalName ??= "Default";
string trusted = optionalName!;
6Чим відрізняються `const`, `readonly` та `static readonly`?
`const` — це константа часу компіляції, що обмежується примітивами, значеннями перелічень (`enum`), рядками або `null`, а її значення підставляється у залежні збірки (assemblies) під час компіляції. `readonly` — це поле екземпляра, якому можна присвоїти значення під час оголошення або в конструкторі. `static readonly` — це одне поле на тип, якому присвоюється значення під час оголошення або у статичному конструкторі. Публічні значення, які можуть змінюватися в різних версіях бібліотеки, безпечніше оголошувати як `static readonly`, а не як публічні `const`, оскільки клієнти можуть зберігати старе підставлене (inlined) значення `const`, доки їх не буде перекомпільовано.
public const int MaxAttempts = 3;
private readonly Guid _id = Guid.NewGuid();
public static readonly TimeSpan Timeout =
TimeSpan.FromSeconds(30);
Для звичайних класів `Equals` та `==` за замовчуванням порівнюють посилання. Багато вбудованих типів та `record` порівнюються за значенням. Якщо `Equals` перевизначено, `GetHashCode` має бути узгодженим: якщо `a.Equals(b)` істинне, обидва об'єкти повинні мати однаковий хеш-код. Зворотне не гарантується. Ніколи не змінюйте поля, які використовуються для перевірки рівності або хешування, поки об'єкт є ключем у `Dictionary` або елементом `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` змінює віртуальну поведінку, тому викликаний метод залежить від типу об'єкта під час виконання. Ключове слово `new` приховує члена базового класу, і вибраний член залежить від статичного типу змінної. Для поліморфізму зазвичай потрібні `virtual` та `override`; приховування за допомогою `new` зустрічається рідше і може зробити поведінку неочевидною.
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
9Чим абстрактний клас відрізняється від інтерфейсу?
Абстрактний клас може містити стан, конструктори, поля, реалізовані методи, абстрактні методи та захищені члени. Інтерфейс описує контракт; сучасні інтерфейси можуть включати реалізації за замовчуванням та статичні абстрактні члени, але вони все одно не є звичайним місцем для зберігання стану екземпляра. Клас може успадковувати один базовий клас, але реалізовувати кілька інтерфейсів. Використовуйте абстрактний клас для спільної бази та спільної реалізації; використовуйте інтерфейс для визначення можливостей (capabilities) та меж.
public interface IAsyncRepository<T>
{
Task<T?> FindAsync(
int id,
CancellationToken cancellationToken);
}
10Що таке узагальнення (generics) і чому вони корисні?
Узагальнення дозволяють писати повторно використовуваний типобезпечний код. Вони переносять багато перевірок на етап компіляції, усувають необхідність ручного приведення типів та дозволяють уникнути пакування (boxing) для значимих типів (value types). Обмеження узагальнень (generic constraints) описують, що саме повинен підтримувати параметр типу, наприклад, бути класом, структурою, реалізацією інтерфейсу, базовим класом, некерованим типом або мати публічний конструктор без параметрів.
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();
}
Коваріантність дозволяє коду використовувати більш конкретний тип там, де очікується загальніший тип результату (produced type), наприклад, `IEnumerable<string>` замість `IEnumerable<object>`. Вона позначається ключовим словом `out` в інтерфейсах та делегатах. Контраваріантність працює у зворотному напрямку для значень, що приймаються (consumed values), і позначається ключовим словом `in`. Варіантність працює лише для інтерфейсів та делегатів із посилальними типами; `IEnumerable<int>` не може стати `IEnumerable<object>`, оскільки це вимагало б пакування (boxing), а `List<string>` не можна присвоїти змінній типу `List<object>`.
12Чим IEnumerable<T> відрізняється від IQueryable<T>?
IEnumerable<T> представляє перелічення в .NET, а оператори LINQ (Language Integrated Query) зазвичай приймають делегати, такі як Func<T, bool>. IQueryable<T> зберігає дерева виразів, наприклад Expression<Func<T, bool>>, які провайдер, такий як Entity Framework Core, може транслювати в SQL (Structured Query Language). Занадто ранній виклик ToList матеріалізує дані та переносить подальше фільтрування в пам'ять. Крім того, не кожен вираз C# може бути трансльований провайдером запитів. Уникайте витоку IQueryable через усі шари застосунку, якщо тільки це не свідоме архітектурне рішення.
13Що таке відкладене виконання (deferred execution) у LINQ (Language Integrated Query)?
Багато операторів LINQ будують запит, не виконуючи його одразу. Виконання починається тоді, коли запит перелічується (ітерується), або коли викликається термінальна операція, така як ToList, Count або First. Це означає, що зміни джерела перед переліченням можуть вплинути на результати, повторне перелічення може призвести до повторення обчислень або SQL-запитів, а винятки можуть виникнути під час перелічення, а не на етапі побудови запиту.
var query = numbers.Where(x => x > 10);
numbers.Add(42);
foreach (var number in query)
{
Console.WriteLine(number);
}
var materialized = query.ToList();
14Чим відрізняються масив, `List<T>`, `Dictionary<TKey,TValue>` та `HashSet<T>`?
Масив має фіксований розмір і швидкий доступ за індексом. `List<T>` — це динамічний масив із доступом за індексом за час O(1); додавання в кінець (`Add`) виконується за амортизований час O(1), оскільки збільшення ємності передбачає виділення більшого масиву та копіювання існуючих елементів, тоді як вставлення всередину виконується за час O(n). `Dictionary<TKey,TValue>` відображає ключі на значення і зазвичай забезпечує пошук за ключем за час O(1). `HashSet<T>` зберігає унікальні елементи і є ідеальним для перевірки належності до множини. Для колекцій на основі хешування важливою є правильна реалізація методів `Equals` та `GetHashCode`.
var ids = new HashSet<int> { 1, 2, 3 };
if (ids.Contains(userId))
{
// Fast membership check
}
15Чим відрізняються `IEnumerable<T>`, `ICollection<T>`, `IList<T>` та `IReadOnlyCollection<T>`?
`IEnumerable<T>` гарантує лише послідовний перебір елементів. `ICollection<T>` додає властивість `Count` та методи для мутацій, такі як `Add` та `Remove`. `IList<T>` додає доступ за індексом. `IReadOnlyCollection<T>` надає можливість перебору та властивість `Count`, але не дозволяє мутації через цей інтерфейс. Під час проєктування API (Application Programming Interface) слід повертати найвужчий корисний контракт, однак інтерфейси лише для читання не гарантують глибокої незмінності об'єкта, що лежить в їх основі.
`yield return` створює ітератор з відкладеним виконанням (deferred execution). Компілятор перетворює метод на кінцевий автомат (state machine), і значення генеруються по одному під час виклику `MoveNext`. Це дозволяє уникнути проміжних колекцій і добре працює для великих послідовностей. Підводними каменями є час життя об'єктів та багаторазовий перебір (multiple enumeration): не повертайте ліниву послідовність, яка залежить від вивільненого `DbContext` або потоку даних (stream), і пам'ятайте, що виклик `Count` з подальшим `foreach` може виконати ітератор двічі, повторюючи побічні ефекти або операції I/O (Input/Output).
IEnumerable<int> GetEvenNumbers(IEnumerable<int> source)
{
foreach (var number in source)
{
if (number % 2 == 0)
yield return number;
}
}
Делегат — це типобезпечне посилання на метод. Лямбда-вирази дозволяють створювати лаконічні вбудовані функції і зазвичай присвоюються делегатам `Func`, `Action` або `Predicate`. Лямбда-вирази захоплюють змінні, а не знімки їхніх значень, тому лямбда-вираз може спостерігати пізніші значення змінної або циклу. Якщо лямбда-вираз нічого не захоплює, компілятор може закешувати статичний делегат і уникнути виділення пам'яті під замикання; у критичних за продуктивністю ділянках коду наявність замикань та подовжений час життя об'єктів можуть мати суттєве значення.
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;
Подія (event) зазвичай базується на делегаті (delegate), але вона обмежує можливості зовнішнього коду. Зовнішній код може підписуватися та відписуватися, але лише клас-власник може ініціювати (raise) подію. Відкрите поле делегата дозволило б тим, хто його викликає, замінити список викликів або викликати його безпосередньо. Події також створюють посилання від видавця (publisher) до підписника (subscriber), тому довгоживучі видавці можуть спричинити витоки пам'яті, якщо підписники не відписуються.
Використовуйте винятки для виняткових ситуацій, а не для звичайного потоку керування. Перехоплюйте лише ті винятки, які ви можете обробити, уникайте порожніх блоків catch, додавайте контекст під час обгортання і не розкривайте внутрішні деталі користувачам. Використовуйте throw; для повторного генерування винятку зі збереженням оригінального трасування стека (stack trace). Якщо ви обгортаєте виняток, передавайте оригінал як InnerException, наприклад throw new DomainException("...", ex). Надавайте перевагу API (Application Programming Interfaces) в стилі TryParse для очікуваних збоїв.
Фільтри винятків дозволяють виконувати блок `catch` лише за умови виконання додаткової умови. Фільтр обчислюється до розгортання стека (stack unwinding) і до входу в тіло `catch`, тому, якщо він повертає `false`, початковий стек залишається недоторканим для діагностики та дампів пам'яті (crash dumps). Фільтри корисні для кодів стану HTTP, кодів помилок бази даних, розрізнення тимчасових помилок (transient errors) і умовного логування. Робіть фільтри простими та уникайте складних побічних ефектів.
try
{
await SendAsync();
}
catch (HttpRequestException ex)
when (ex.StatusCode == HttpStatusCode.NotFound)
{
// Handle only 404
}