Een gerichte set C#-interviewvragen voor backend-ontwikkelaars. Elk antwoord bevat de onderbouwing die interviewers verwachten, en veel vragen bevatten kleine codevoorbeelden om hardop te bespreken.
Variabelen van een waardetype bevatten doorgaans de waarde zelf, waardoor bij toewijzing de waarde wordt gekopieerd. Variabelen van een referentietype bevatten een verwijzing naar een object, waardoor bij toewijzing de verwijzing wordt gekopieerd en beide variabelen mutaties aan hetzelfde object kunnen waarnemen. De belangrijke nuance is dat de opslaglocatie afhangt van de levensduur en context, en niet alleen van het type: een waarde die lokaal is binnen een methode kan op de stack leven, terwijl een waardetype dat is opgeslagen als klasseveld, array-element, vastgelegde variabele in een closure, ingepakte waarde, of als veld van een asynchrone toestandsmachine, in het geheugen meeleeft met dat specifieke object.
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"
2Wat is het verschil tussen `class`, `struct` en `record`?
Een `class` is een referentietype met objectidentiteit. Een `struct` is een waardetype en is bij uitstek geschikt voor kleine, onveranderlijke waarden zoals coördinaten, bereiken of identificatiecodes. Een `record class` is een referentietype met op waarde gebaseerde gelijkheid; een `record struct` is de waardetype-variant hiervan. Typen aangeduid als `record` zijn compilersyntaxis bovenop gewone CLR (Common Language Runtime)-typen die automatisch ondersteuning genereren voor gelijkheid, `GetHashCode`, `ToString`, deconstructie en kopiëren, inclusief het gebruik van `with`-expressies voor niet-destructieve mutaties. In backend-code zijn `record`-typen nuttig voor modellen die lijken op DTO's (Data Transfer Objects), terwijl grote veranderlijke `struct`-typen riskant zijn, omdat het kopiëren en vervolgens wijzigen van die kopieën tot onverwachte resultaten voor de lezer kan leiden.
public record User(int Id, string Name);
var first = new User(1, "Alice");
var second = new User(1, "Alice");
Console.WriteLine(first == second); // true
3Hoe verschillen de parameters `ref`, `out` en `in` van elkaar?
`ref` geeft een argument door via referentie en vereist dat het voor de aanroep geïnitialiseerd is; de methode kan het lezen en wijzigen. `out` wordt ook doorgegeven via referentie, maar de methode moet een waarde toewijzen voordat deze terugkeert. `in` geeft een `readonly` referentie door, wat het kopiëren van grote `struct`-waarden kan voorkomen, maar het is geen garantie voor diepe onveranderlijkheid: veranderlijke `structs` en niet-`readonly` velden kunnen defensieve kopieën forceren. Op IL-niveau (Intermediate Language) zijn `ref`, `out` en `in` beheerde `byref`-parameters met verschillende C#-regels.
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);
Boxing verpakt een waardetype in een object van het referentietype door een object toe te wijzen en de waarde erin te kopiëren. Unboxing haalt de waarde er weer uit en vereist het exacte, compatibele type. Veelvuldige boxing zorgt voor extra toewijzingen en verhoogt de belasting van de GC (Garbage Collector). Generics (generieke typen) voorkomen veel gevallen van boxing omdat `List<int>` `int`-waarden opslaat zonder elke waarde naar een object te converteren.
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;
5Wat zijn nullable waardetypen en nullable referentietypen?
Nullable waardetypen gebruiken `Nullable<T>` of `T?` en vertegenwoordigen een echte wrapper voor waardetypen tijdens de uitvoering. Nullable referentietypen zijn voornamelijk gebaseerd op compileranalyse: `string?` vertelt de compiler dat een referentie null kan zijn, maar creëert geen nieuw type tijdens de uitvoering. De null-forgiving operator `!` onderdrukt alleen waarschuwingen; deze beschermt niet tegen een `NullReferenceException`.
int? age = null;
string name = "Alice";
string? optionalName = null;
var length = optionalName?.Length;
var actualName = optionalName ?? "Unknown";
optionalName ??= "Default";
string trusted = optionalName!;
6Hoe verschillen `const`, `readonly` en `static readonly` van elkaar?
`const` is een compile-time constante die beperkt is tot primitieve typen, enum-waarden, `string` of `null`, en de waarde ervan wordt tijdens de compilatie gesubstitueerd in de assemblies die deze gebruiken. `readonly` is een instantieveld dat kan worden toegewezen in een declaratie of constructor. `static readonly` is één veld per type, toegewezen in een declaratie of statische constructor. Publieke waarden die mogelijk veranderen tussen versies van een bibliotheek, zijn veiliger als `static readonly` dan als publieke `const`, omdat clients een oude, inline ingevoegde `const` kunnen behouden totdat ze opnieuw worden gecompileerd.
public const int MaxAttempts = 3;
private readonly Guid _id = Guid.NewGuid();
public static readonly TimeSpan Timeout =
TimeSpan.FromSeconds(30);
Bij gewone klassen vergelijken Equals en == standaard op basis van referentie. Veel ingebouwde typen en records vergelijken op basis van waarde. Als Equals wordt overschreven, moet GetHashCode consistent zijn: als a.Equals(b) waar is, moeten beide objecten dezelfde hashcode hebben. Het omgekeerde is niet gegarandeerd. Wijzig nooit velden die worden gebruikt voor gelijkheid of hashing zolang het object een sleutel in een Dictionary of een element in een HashSet is.
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 wijzigt het virtuele gedrag, waardoor de aangeroepen methode afhangt van het type van het object tijdens uitvoering. new verbergt een lid van de basisklasse, en het geselecteerde lid hangt af van het statische type van de variabele. Voor polymorfisme gebruik je normaal gesproken virtual en override; het verbergen met new komt minder vaak voor en kan het gedrag verwarrend maken.
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
9Hoe verschilt een abstracte klasse van een interface?
Een abstracte klasse kan status, constructors, velden, geïmplementeerde methoden, abstracte methoden en `protected` leden bevatten. Een interface beschrijft een contract; moderne interfaces kunnen standaardimplementaties en statische abstracte leden bevatten, maar ze zijn nog steeds niet de normale plek voor instantiestatus. Een klasse kan van één basisklasse overerven, maar meerdere interfaces implementeren. Gebruik een abstracte klasse voor een gedeelde basis en gedeelde implementatie; gebruik een interface voor capaciteiten en grenzen.
public interface IAsyncRepository<T>
{
Task<T?> FindAsync(
int id,
CancellationToken cancellationToken);
}
Met generics kun je herbruikbare, typeveilige code schrijven. Ze verplaatsen veel controles naar de compilatietijd, maken handmatige casts overbodig en kunnen boxing voorkomen bij waardetypen. Generieke constraints beschrijven wat een typeparameter moet ondersteunen, zoals een klasse, struct, interface-implementatie, basisklasse of unmanaged type zijn, of beschikken over een openbare constructor zonder parameters.
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();
}
Covariantie stelt code in staat om een specifieker type te gebruiken waar een algemener geproduceerd type wordt verwacht, zoals IEnumerable<string> als IEnumerable<object>. Dit wordt gemarkeerd met out op interfaces en delegates. Contravariantie werkt de andere kant op voor geconsumeerde waarden en wordt gemarkeerd met in. Variantie werkt alleen voor interfaces en delegates met referentietypen; IEnumerable<int> kan geen IEnumerable<object> worden omdat dit boxing zou vereisen, en List<string> kan niet worden toegewezen aan List<object>.
12Wat is het verschil tussen IEnumerable<T> en IQueryable<T>?
IEnumerable<T> vertegenwoordigt .NET-enumeratie en LINQ (Language Integrated Query)-operators accepteren meestal delegates zoals Func<T, bool>. IQueryable<T> slaat expressiebomen op, bijvoorbeeld Expression<Func<T, bool>>, die een provider zoals Entity Framework Core kan vertalen naar SQL (Structured Query Language). Het te vroeg aanroepen van ToList materialiseert gegevens en verplaatst latere filtering naar het geheugen. Bovendien kan niet elke C#-expressie worden vertaald door een queryprovider. Vermijd dat IQueryable door alle applicatielagen lekt, tenzij dit een bewuste ontwerpkeuze is.
13Wat is uitgestelde uitvoering (deferred execution) in LINQ (Language Integrated Query)?
Veel LINQ-operatoren bouwen een query op zonder deze direct uit te voeren. De uitvoering begint pas wanneer er door de query wordt geïtereerd of wanneer een terminale operatie zoals `ToList`, `Count` of `First` wordt aangeroepen. Dit betekent dat wijzigingen in de bron vóór de iteratie de resultaten kunnen beïnvloeden, herhaalde iteratie het werk of SQL-query's (Structured Query Language) kan herhalen, en excepties kunnen optreden tijdens de iteratie in plaats van tijdens het opbouwen van de query.
var query = numbers.Where(x => x > 10);
numbers.Add(42);
foreach (var number in query)
{
Console.WriteLine(number);
}
var materialized = query.ToList();
14Hoe verschillen een array, List<T>, Dictionary<TKey,TValue> en HashSet<T> van elkaar?
Een array heeft een vaste grootte en snelle toegang via een index. List<T> is een dynamische array met O(1) toegangstijd via een index; toevoegen aan het einde is geamortiseerd O(1) omdat het vergroten van de capaciteit een grotere array toewijst en bestaande elementen kopieert, terwijl invoegen in het midden O(n) is. Dictionary<TKey,TValue> koppelt sleutels aan waarden en biedt meestal O(1) opzoektijd voor sleutels. HashSet<T> slaat unieke elementen op en is ideaal voor lidmaatschapscontroles. Voor op hashes gebaseerde collecties zijn correcte implementaties van Equals en GetHashCode essentieel.
var ids = new HashSet<int> { 1, 2, 3 };
if (ids.Contains(userId))
{
// Fast membership check
}
15Wat is het verschil tussen `IEnumerable<T>`, `ICollection<T>`, `IList<T>` en `IReadOnlyCollection<T>`?
`IEnumerable<T>` garandeert alleen sequentiële enumeratie. `ICollection<T>` voegt hier `Count` en mutatiemethoden zoals `Add` en `Remove` aan toe. `IList<T>` voegt geïndexeerde toegang toe. `IReadOnlyCollection<T>` stelt enumeratie en `Count` beschikbaar, maar staat geen mutaties toe via die interface. Bij het ontwerpen van een API moet je altijd het smalst mogelijke, bruikbare contract retourneren, maar let op: alleen-lezen interfaces garanderen geen diepe onveranderlijkheid van het onderliggende object.
`yield return` creëert een iterator met uitgestelde uitvoering. De compiler transformeert de methode in een toestandsmachine, en waarden worden één voor één geproduceerd wanneer `MoveNext` wordt aangeroepen. Dit vermijdt tussentijdse collecties en werkt goed voor grote reeksen. De valkuilen zijn de levensduur en meervoudige iteratie: retourneer geen reeks met vertraagde evaluatie die afhankelijk is van een vrijgegeven `DbContext` of stream, en onthoud dat `Count` gevolgd door `foreach` de iterator twee keer kan uitvoeren, waardoor bijwerkingen of I/O worden herhaald.
IEnumerable<int> GetEvenNumbers(IEnumerable<int> source)
{
foreach (var number in source)
{
if (number % 2 == 0)
yield return number;
}
}
Een delegate is een typeveilige verwijzing naar een methode. Lambda's bieden beknopte inline functies en worden doorgaans toegewezen aan Func-, Action- of Predicate-delegates. Lambda's leggen variabelen vast, niet slechts een momentopname van hun waarden, zodat een lambda een latere lus- of variabelewaarde kan waarnemen. Als een lambda niets vastlegt, kan de compiler een statische delegate cachen en de toewijzing van een closure vermijden; in prestatiekritieke code kunnen closures en een verlengde levensduur van objecten een rol spelen.
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;
Een event maakt onder de motorkap meestal gebruik van een delegate, maar het beperkt wat externe code ermee kan doen. Externe code kan zich abonneren en afmelden, maar alleen de klasse die eigenaar is, kan het event activeren. Een publiek delegate-veld zou aanroepers in staat stellen de aanroeplijst te vervangen of deze direct te activeren. Events creëren bovendien referenties van de publisher naar de subscriber, waardoor langlevende publishers geheugenlekken kunnen veroorzaken als subscribers zich niet afmelden.
Gebruik exceptions voor uitzonderlijke situaties, niet voor het normale programmaverloop. Vang alleen exceptions op die je kunt afhandelen, vermijd lege `catch`-blokken, voeg context toe bij het inpakken van exceptions en lek geen interne details naar gebruikers. Gebruik `throw;` om een exception opnieuw op te werpen met behoud van de oorspronkelijke stacktrace. Als je een exception inpakt, geef de oorspronkelijke fout dan door als `InnerException`, bijvoorbeeld met `throw new DomainException("...", ex)`. Geef de voorkeur aan `TryParse`-achtige API's voor verwachte mislukkingen.
Exception filters zorgen ervoor dat een `catch`-blok alleen wordt uitgevoerd wanneer een aanvullende voorwaarde waar is. Het filter wordt geëvalueerd vóór het afwikkelen van de callstack en voordat de uitvoering het `catch`-blok binnengaat. Als het filter onwaar retourneert, blijft de originele stack daardoor intact voor diagnostiek en crashdumps. Filters zijn nuttig voor HTTP-statuscodes, databasefoutcodes, het onderscheiden van tijdelijke fouten en conditionele logging. Houd filters simpel en vermijd complexe neveneffecten.
try
{
await SendAsync();
}
catch (HttpRequestException ex)
when (ex.StatusCode == HttpStatusCode.NotFound)
{
// Handle only 404
}