Un set mirato di domande da colloquio su C# per sviluppatori backend. Ogni risposta include il ragionamento che gli intervistatori si aspettano e molte includono piccoli esempi di codice che puoi discutere a voce alta.
1In che modo i tipi valore differiscono dai tipi riferimento?
Le variabili di tipo valore contengono solitamente il valore stesso, quindi l'assegnazione ne copia il valore. Le variabili di tipo riferimento contengono un riferimento a un oggetto, quindi l'assegnazione copia il riferimento ed entrambe le variabili possono osservare le mutazioni dello stesso oggetto. La sfumatura importante è che l'allocazione della memoria dipende dal ciclo di vita e dal contesto, non solo dal tipo: un valore locale a un metodo può risiedere nello stack, mentre un tipo valore memorizzato come campo di una classe, elemento di un array, cattura di una closure, valore boxed o campo di una macchina a stati `async` risiede insieme a quell'oggetto.
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"
Una class è un tipo di riferimento con identità dell'oggetto. Una struct è un tipo di valore ed è ideale per piccoli valori immutabili come coordinate, intervalli o identificatori. Una record class è un tipo di riferimento con uguaglianza basata sul valore; la record struct è la sua versione come tipo di valore. I record sono una sintassi del compilatore basata sui normali tipi del CLR (Common Language Runtime) che genera automaticamente il supporto per l'uguaglianza, GetHashCode, ToString, la decostruzione e la copia, incluse le espressioni with per la mutazione non distruttiva. Nel codice di backend, i record sono utili per modelli simili ai DTO (Data Transfer Object), mentre le struct di grandi dimensioni e mutabili sono rischiose perché copiarle e modificare le copie può creare confusione in chi legge il codice.
public record User(int Id, string Name);
var first = new User(1, "Alice");
var second = new User(1, "Alice");
Console.WriteLine(first == second); // true
3In cosa differiscono i parametri `ref`, `out` e `in`?
`ref` passa un argomento per riferimento e richiede che sia inizializzato prima della chiamata; il metodo può leggerlo e modificarlo. Anche `out` passa per riferimento, ma il metodo deve assegnargli un valore prima di ritornare. `in` passa un riferimento in sola lettura (`readonly byref`), il che può evitare la copia per le `struct` di grandi dimensioni, ma non fornisce una garanzia di immutabilità profonda: le `struct` mutabili e i membri non `readonly` possono forzare copie difensive. A livello di codice intermedio (IL), `ref`, `out` e `in` sono parametri per riferimento gestiti con regole C# differenti.
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);
Il boxing incapsula un tipo di valore in un oggetto di tipo di riferimento allocando un oggetto e copiandovi il valore all'interno. L'unboxing estrae nuovamente il valore e richiede il tipo esatto compatibile. Un boxing frequente aumenta le allocazioni e la pressione sul GC (Garbage Collector). I generics evitano molti casi di boxing perché `List<int>` memorizza i valori `int` senza convertire ciascun valore in `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;
5Cosa sono i tipi di valore nullable e i tipi di riferimento nullable?
I tipi di valore nullable utilizzano `Nullable<T>` o `T?` e rappresentano un reale wrapper a tempo di esecuzione per i tipi di valore. I tipi di riferimento nullable si basano principalmente sull'analisi del compilatore: `string?` indica al compilatore che un riferimento potrebbe essere null, ma non crea un nuovo tipo a runtime. L'operatore null-forgiving `!` sopprime semplicemente gli avvisi; non protegge da una `NullReferenceException`.
int? age = null;
string name = "Alice";
string? optionalName = null;
var length = optionalName?.Length;
var actualName = optionalName ?? "Unknown";
optionalName ??= "Default";
string trusted = optionalName!;
6In che modo differiscono `const`, `readonly` e `static readonly`?
`const` è una costante a tempo di compilazione limitata a primitive, valori `enum`, `string` o `null`, e il suo valore viene sostituito inline negli assembly che la utilizzano durante la compilazione. `readonly` è un campo di istanza che può essere assegnato nella dichiarazione o nel costruttore. `static readonly` è un campo univoco per tipo, assegnato in una dichiarazione o in un costruttore statico. I valori pubblici che potrebbero cambiare nelle future versioni di una libreria sono più sicuri come `static readonly` rispetto a un `public const`, poiché i client potrebbero mantenere una vecchia `const` inline finché non vengono ricompilati.
public const int MaxAttempts = 3;
private readonly Guid _id = Guid.NewGuid();
public static readonly TimeSpan Timeout =
TimeSpan.FromSeconds(30);
7Come funzionano insieme Equals, == e GetHashCode?
Per le classi ordinarie, `Equals` e `==` confrontano i riferimenti per impostazione predefinita. Molti tipi integrati e `record` confrontano per valore. Se `Equals` viene sovrascritto, `GetHashCode` deve essere coerente: se `a.Equals(b)` è vero, entrambi gli oggetti devono avere lo stesso hash code. Il contrario non è garantito. Non bisogna mai modificare i campi utilizzati per l'uguaglianza o l'hashing mentre l'oggetto è una chiave di un `Dictionary` o un elemento di un `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 modifica il comportamento virtuale, quindi il metodo chiamato dipende dal tipo a tempo di esecuzione dell'oggetto. new nasconde un membro della classe base e il membro selezionato dipende dal tipo statico della variabile. Per il polimorfismo normalmente si desiderano virtual e override; l'occultamento con new è meno comune e può rendere il comportamento confuso.
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
9In cosa differisce una classe astratta da un'interfaccia?
Una classe astratta può contenere stato, costruttori, campi, metodi implementati, metodi astratti e membri protetti. Un'interfaccia descrive un contratto; le interfacce moderne possono includere implementazioni di default e membri astratti statici, ma continuano a non essere il luogo abituale in cui mantenere lo stato dell'istanza. Una classe può ereditare da un'unica classe base ma implementare molteplici interfacce. Utilizza una classe astratta per una base e un'implementazione condivise; utilizza un'interfaccia per funzionalità e definizione dei confini.
public interface IAsyncRepository<T>
{
Task<T?> FindAsync(
int id,
CancellationToken cancellationToken);
}
I generics permettono di scrivere codice riutilizzabile e sicuro dal punto di vista dei tipi (type-safe). Spostano molti controlli a tempo di compilazione, rimuovono i cast manuali e possono evitare il boxing per i tipi valore. I vincoli (generic constraints) descrivono ciò che un parametro di tipo deve supportare, ad esempio essere una classe, una struct, un'implementazione di interfaccia, una classe base, un tipo non gestito (unmanaged type) o avere un costruttore pubblico senza parametri.
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();
}
La covarianza permette al codice di usare un tipo più specifico dove è atteso un tipo prodotto più generico, come ad esempio `IEnumerable<string>` al posto di `IEnumerable<object>`. È contrassegnata con `out` nelle interfacce e nei delegati. La controvarianza va nella direzione opposta per i valori consumati ed è contrassegnata con `in`. La varianza funziona solo per le interfacce e i delegati con i tipi di riferimento; `IEnumerable<int>` non può diventare `IEnumerable<object>` perché ciò richiederebbe il boxing, e `List<string>` non è assegnabile a `List<object>`.
12In cosa differisce IEnumerable<T> da IQueryable<T>?
IEnumerable<T> rappresenta l'enumerazione in .NET e gli operatori LINQ (Language Integrated Query) solitamente accettano delegati come Func<T, bool>. IQueryable<T> memorizza alberi di espressioni, ad esempio Expression<Func<T, bool>>, che un provider come Entity Framework Core può tradurre in SQL (Structured Query Language). Chiamare ToList troppo presto materializza i dati e sposta i filtraggi successivi in memoria. Inoltre, non tutte le espressioni C# possono essere tradotte da un query provider. Evita di esporre IQueryable attraverso tutti i livelli dell'applicazione, a meno che non sia una scelta architetturale deliberata.
13Cos'è l'esecuzione differita in LINQ (Language Integrated Query)?
Molti operatori LINQ costruiscono una query senza eseguirla immediatamente. L'esecuzione inizia quando la query viene enumerata o quando viene chiamata un'operazione terminale come `ToList`, `Count` o `First`. Questo significa che le modifiche all'origine dati prima dell'enumerazione possono influenzare i risultati, un'enumerazione ripetuta può ripetere il lavoro o le query SQL (Structured Query Language), e le eccezioni possono verificarsi durante l'enumerazione anziché durante la costruzione della query.
var query = numbers.Where(x => x > 10);
numbers.Add(42);
foreach (var number in query)
{
Console.WriteLine(number);
}
var materialized = query.ToList();
14In cosa differiscono un array, `List<T>`, `Dictionary<TKey,TValue>` e `HashSet<T>`?
Un array ha una dimensione fissa e un accesso indicizzato rapido. `List<T>` è un array dinamico con accesso indicizzato O(1); l'aggiunta alla fine è O(1) ammortizzato perché l'aumento della capacità alloca un array più grande e copia gli elementi esistenti, mentre l'inserimento nel mezzo richiede un tempo O(n). `Dictionary<TKey,TValue>` mappa le chiavi ai valori e di solito fornisce una ricerca della chiave O(1). `HashSet<T>` memorizza elementi univoci ed è ideale per i controlli di appartenenza. Per le collezioni basate su hash, sono essenziali le implementazioni corrette di `Equals` e `GetHashCode`.
var ids = new HashSet<int> { 1, 2, 3 };
if (ids.Contains(userId))
{
// Fast membership check
}
15Quali sono le differenze tra IEnumerable<T>, ICollection<T>, IList<T> e IReadOnlyCollection<T>?
IEnumerable<T> garantisce solo l'enumerazione sequenziale. ICollection<T> aggiunge la proprietà Count e metodi di mutazione come Add e Remove. IList<T> aggiunge l'accesso tramite indice. IReadOnlyCollection<T> espone l'enumerazione e Count, ma non la mutazione attraverso quell'interfaccia. La progettazione di un'API dovrebbe restituire il contratto utile più restrittivo, ma le interfacce di sola lettura non garantiscono un'immutabilità profonda dell'oggetto sottostante.
yield return crea un iteratore con esecuzione differita. Il compilatore trasforma il metodo in una macchina a stati e i valori vengono prodotti uno a uno quando viene chiamato MoveNext. Questo evita la creazione di collezioni intermedie e funziona bene per le sequenze di grandi dimensioni. Le insidie principali riguardano il ciclo di vita e l'enumerazione multipla: non si deve restituire una sequenza lazy che dipende da un DbContext o da uno stream già rilasciati (disposed), e bisogna ricordare che l'uso di Count seguito da un foreach può eseguire l'iteratore due volte, ripetendo gli effetti collaterali o le operazioni di I/O.
IEnumerable<int> GetEvenNumbers(IEnumerable<int> source)
{
foreach (var number in source)
{
if (number % 2 == 0)
yield return number;
}
}
17Cosa sono i delegati (delegate) e le espressioni lambda?
Un delegato è un riferimento type-safe a un metodo. Le espressioni lambda forniscono funzioni inline concise e vengono comunemente assegnate ai delegati `Func`, `Action` o `Predicate`. Le lambda catturano le variabili, non un'istantanea dei loro valori, quindi una lambda può osservare il valore successivo di una variabile o di un ciclo. Se una lambda non cattura nulla, il compilatore può memorizzare in cache un delegato statico ed evitare l'allocazione della chiusura (closure); nel codice critico per le prestazioni, le closure e il prolungamento del ciclo di vita degli oggetti possono avere un impatto significativo.
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;
Un event è solitamente supportato da un delegate, ma pone delle restrizioni a ciò che il codice esterno può fare. Il codice esterno può iscriversi (subscribe) e annullare l'iscrizione (unsubscribe), ma solo la classe proprietaria può sollevare l'evento. Un campo delegate pubblico consentirebbe ai chiamanti di sostituire la lista di invocazione o di invocarla direttamente. Gli event creano anche riferimenti dal publisher al subscriber, pertanto i publisher con un lungo ciclo di vita possono causare memory leak se i subscriber non annullano l'iscrizione.
19Come dovrebbero essere gestite le eccezioni in C#?
Usa le eccezioni per situazioni eccezionali, non per il normale flusso di controllo. Cattura solo le eccezioni che puoi gestire, evita i blocchi catch vuoti, aggiungi contesto quando le incapsuli e non far trapelare dettagli interni agli utenti. Usa throw; per rilanciare un'eccezione preservando la traccia dello stack (stack trace) originale. Se incapsuli un'eccezione, passa quella originale come InnerException, ad esempio throw new DomainException("...", ex). Preferisci le API in stile TryParse per i fallimenti previsti.
20Cosa sono i filtri delle eccezioni (exception filter)?
I filtri delle eccezioni consentono a un blocco `catch` di essere eseguito solo quando una condizione aggiuntiva è vera. Il filtro viene valutato prima dello svuotamento dello stack (stack unwinding) e prima di entrare nel corpo del `catch`; di conseguenza, quando restituisce falso, lo stack originale rimane intatto per la diagnostica e i crash dump. I filtri sono utili per i codici di stato HTTP, i codici di errore del database, per distinguere gli errori temporanei e per il logging condizionale. Mantieni i filtri semplici ed evita effetti collaterali complessi.
try
{
await SendAsync();
}
catch (HttpRequestException ex)
when (ex.StatusCode == HttpStatusCode.NotFound)
{
// Handle only 404
}