C#-haastatteluun valmistautuminen

C#-työhaastattelukysymykset ja vastaukset 2026

Kohdennettu C#-haastattelukysymysten kokoelma backend-kehittäjille. Jokainen vastaus sisältää haastattelijoiden odottamat perustelut, ja monissa on pieniä koodiesimerkkejä, joista voit keskustella ääneen.

Aloita C#-harjoitushaastatteluEi vaadi luottokorttia. 1 ilmainen sessio saatavilla.
Teknisten haastattelujen harjoittelu englanniksiTila, jossa ei-äidinkieliset puhujat voivat harjoitella teknisen haastattelun läpäisemistä.

Tyypit ja kielen perusteet

1Miten arvotyypit (value types) eroavat viitetyypeistä (reference types)?

Arvotyyppiset muuttujat sisältävät yleensä itse arvon, joten sijoitusoperaatio kopioi arvon. Viitetyyppiset muuttujat sisältävät viittauksen olioon, joten sijoitusoperaatio kopioi viittauksen, ja molemmat muuttujat voivat havaita samaan olioon tehdyt muutokset. Tärkeä vivahde on se, että tallennuspaikka riippuu elinkaaresta ja kontekstista, ei pelkästään tyypistä: metodin paikallinen arvo voi elää pinossa, kun taas luokan kenttänä, taulukon alkiona, sulkeuman (closure) kaappaamana, paketoituna arvonana (boxed value) tai asynkronisen metodin tilakoneen kenttänä tallennettu arvotyyppi elää kyseisen olion mukana.

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"
Kokeile vastata tähän kysymykseen tekoälyvalmentajan kanssa

2Miten `class`, `struct` ja `record` eroavat toisistaan?

`class` on viittaustyyppi, jolla on olioidentiteetti. `struct` on arvotyyppi ja sopii parhaiten pienille, muuttumattomille arvoille, kuten koordinaateille, lukuväleille tai tunnisteille. `record class` on viittaustyyppi, jolla on arvoihin perustuva yhtäsuuruus; `record struct` on sen arvotyyppiversio. `record`-tyypit ovat kääntäjän tarjoama syntaksi tavallisten CLR-tyyppien (Common Language Runtime) päälle. Ne generoivat yhtäsuuruusvertailun, `GetHashCode`- ja `ToString`-metodit, tuen purkamiselle (deconstruction) sekä kopioinnille, mihin sisältyvät `with`-lausekkeet arvojen ei-tuhoavaa muokkaamista varten. Taustajärjestelmien koodissa `record`-tyypit ovat hyödyllisiä DTO-mallien (Data Transfer Object) kaltaisissa rakenteissa, kun taas suuret, muokattavat `struct`-tyypit ovat riskialttiita, koska kopioiden tekeminen ja niiden muokkaaminen voivat yllättää koodin lukijan.

public record User(int Id, string Name);

var first = new User(1, "Alice");
var second = new User(1, "Alice");

Console.WriteLine(first == second); // true
Kokeile vastata tähän kysymykseen tekoälyvalmentajan kanssa

3Miten `ref`-, `out`- ja `in`-parametrit eroavat toisistaan?

`ref` välittää argumentin viittauksena ja vaatii, että se on alustettu ennen kutsua; metodi voi lukea ja muuttaa sitä. Myös `out` välittää argumentin viittauksena, mutta metodin on sijoitettava sille arvo ennen paluuta. `in` välittää vain luku -tyyppisen viittauksen, mikä voi välttää kopioinnin suurten `struct`-rakenteiden kohdalla, mutta se ei takaa syvää muuttumattomuutta: muuttuvat `struct`-rakenteet ja muut kuin vain luku -jäsenet voivat pakottaa tekemään puolustavia kopioita. IL (Intermediate Language) -tasolla `ref`, `out` ja `in` ovat hallittuja viiteparametreja, joihin sovelletaan eri C#-sääntöjä.

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);
Kokeile vastata tähän kysymykseen tekoälyvalmentajan kanssa

4Mitä ovat pakkaaminen (boxing) ja purkaminen (unboxing)?

Pakkaaminen (boxing) käärii arvotyypin viitetyyppiseksi olioksi varaamalla muistia uudelle oliolle ja kopioimalla arvon sen sisään. Purkaminen (unboxing) erottaa arvon takaisin ja vaatii täsmälleen alkuperäistä vastaavan tyypin. Toistuva pakkaaminen lisää muistinvarauksia ja roskienkerääjän (Garbage Collector, GC) kuormaa. Geneeriset tyypit välttävät monia pakkaamistilanteita, koska `List<int>` tallentaa `int`-arvoja muuntamatta jokaista arvoa `object`-tyyppiseksi.

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;
Kokeile vastata tähän kysymykseen tekoälyvalmentajan kanssa

5Mitä ovat nollattavat arvotyypit (nullable value types) ja nollattavat viitetyypit (nullable reference types)?

Nollattavat arvotyypit käyttävät `Nullable<T>`-rakennetta tai `T?`-syntaksia, ja ne ovat todellisia ajonaikaisia kääreitä arvotyypeille. Nollattavat viitetyypit perustuvat pääasiassa kääntäjän tekemään analyysiin: `string?` kertoo kääntäjälle, että viite saattaa olla `null`, mutta se ei luo uutta ajonaikaista tyyppiä. Varoitukset ohittava null-forgiving-operaattori `!` ainoastaan vaimentaa kääntäjän varoituksia; se ei suojaa `NullReferenceException`-poikkeuksilta ajonaikana.

int? age = null;

string name = "Alice";
string? optionalName = null;

var length = optionalName?.Length;
var actualName = optionalName ?? "Unknown";
optionalName ??= "Default";

string trusted = optionalName!;
Kokeile vastata tähän kysymykseen tekoälyvalmentajan kanssa

6Miten `const`, `readonly` ja `static readonly` eroavat toisistaan?

`const` on käännösaikainen vakio, joka on rajoitettu primitiivisiin tyyppeihin, `enum`-arvoihin, `string`-tyyppiin tai `null`-arvoon, ja sen arvo korvataan sitä käyttäviin assemblyihin käännösvaiheessa. `readonly` on ilmentymän kenttä (instance field), jolle voidaan antaa arvo määrittelyn yhteydessä tai konstruktorissa. `static readonly` on tyyppikohtainen kenttä, joka saa arvonsa määrittelyn yhteydessä tai staattisessa konstruktorissa. Julkiset arvot, jotka saattavat muuttua kirjaston versioiden välillä, ovat turvallisempia `static readonly` -kenttinä kuin julkisina `const`-vakioina, koska asiakaskoodi voi säilyttää vanhan sisällytetyn (inlined) `const`-arvon, kunnes se käännetään uudelleen.

public const int MaxAttempts = 3;

private readonly Guid _id = Guid.NewGuid();

public static readonly TimeSpan Timeout =
    TimeSpan.FromSeconds(30);
Kokeile vastata tähän kysymykseen tekoälyvalmentajan kanssa

7Miten `Equals`, `==` ja `GetHashCode` toimivat yhdessä?

Tavallisilla luokilla `Equals` ja `==` vertailevat oletuksena viittauksia. Monet sisäänrakennetut tyypit ja `record`-tyypit vertailevat arvoja. Jos `Equals` ylikirjoitetaan, `GetHashCode`-metodin on oltava johdonmukainen: jos `a.Equals(b)` on tosi, molemmilla olioilla on oltava sama hajautusarvo. Käänteinen ei ole taattua. Älä koskaan muuta vertailuun tai hajautukseen käytettäviä kenttiä silloin, kun olio on `Dictionary`-avaimena tai `HashSet`-alkiona.

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();
}
Kokeile vastata tähän kysymykseen tekoälyvalmentajan kanssa

8Miten `override` eroaa `new`-avainsanasta?

`override` muuttaa virtuaalista käyttäytymistä, jolloin kutsuttava metodi riippuu olion ajonaikaisesta tyypistä. `new` piilottaa kantaluokan jäsenen, ja valittu jäsen riippuu muuttujan staattisesta tyypistä. Polymorfismin saavuttamiseksi käytetään yleensä `virtual`- ja `override`-avainsanoja; piilottaminen `new`-avainsanalla on harvinaisempaa ja voi tehdä käyttäytymisestä hämmentävää.

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
Kokeile vastata tähän kysymykseen tekoälyvalmentajan kanssa

9Miten abstrakti luokka eroaa rajapinnasta?

Abstrakti luokka voi sisältää tilaa, konstruktoreita, kenttiä, toteutettuja metodeja, abstrakteja metodeja ja suojattuja (protected) jäseniä. Rajapinta kuvaa sopimusta; modernit rajapinnat voivat sisältää oletustoteutuksia ja staattisia abstrakteja jäseniä, mutta ne eivät silti ole normaali paikka ilmentymän tilalle. Luokka voi periä yhden kantaluokan, mutta toteuttaa useita rajapintoja. Käytä abstraktia luokkaa jaettuun perustaan ja jaettuun toteutukseen; käytä rajapintaa kyvykkyyksien ja rajojen määrittelyyn.

public interface IAsyncRepository<T>
{
    Task<T?> FindAsync(
        int id,
        CancellationToken cancellationToken);
}
Kokeile vastata tähän kysymykseen tekoälyvalmentajan kanssa

Geneeriset tyypit, kokoelmat ja LINQ

10Mitä ovat geneeriset tyypit ja miksi ne ovat hyödyllisiä?

Geneeristen tyyppien avulla voi kirjoittaa uudelleenkäytettävää, tyyppiturvallista koodia. Ne siirtävät monia tarkistuksia käännösaikaan, poistavat manuaalisten tyyppimuunnosten tarpeen ja voivat välttää arvotyyppien laatikoinnin (boxing). Geneeriset rajoitteet kuvaavat, mitä tyyppiparametrin on tuettava, kuten esimerkiksi sen on oltava luokka, `struct`, rajapinnan toteutus, perusluokka, hallitsematon tyyppi tai sillä on oltava julkinen parametriton konstruktori.

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();
}
Kokeile vastata tähän kysymykseen tekoälyvalmentajan kanssa

11Mitä ovat kovarianssi ja kontravarianssi?

Kovarianssi sallii tarkemman tyypin käytön tilanteessa, jossa odotetaan yleisempää tuotettua tyyppiä, esimerkiksi `IEnumerable<string>`-tyypin käytön `IEnumerable<object>`-tyyppinä. Se merkitään rajapinnoissa ja delegaateissa avainsanalla `out`. Kontravarianssi toimii päinvastaiseen suuntaan vastaanotetuille arvoille, ja se merkitään avainsanalla `in`. Varianssi toimii ainoastaan viitetyyppejä käyttävien rajapintojen ja delegaattien kohdalla. `IEnumerable<int>` ei voi muuttua `IEnumerable<object>`-tyypiksi, koska se vaatisi arvon paketointia (boxing), eikä `List<string>` ole sijoitettavissa `List<object>`-tyyppiin.

IEnumerable<string> strings = new List<string>();
IEnumerable<object> objects = strings; // covariance

Action<object> printObject = Console.WriteLine;
Action<string> printString = printObject; // contravariance

interface IProducer<out T> { T Produce(); }
interface IConsumer<in T> { void Consume(T value); }
Kokeile vastata tähän kysymykseen tekoälyvalmentajan kanssa

12Miten `IEnumerable<T>` eroaa `IQueryable<T>`:stä?

`IEnumerable<T>` edustaa .NET-luettelointia, ja LINQ-operaattorit (Language Integrated Query) ottavat yleensä vastaan delegaatteja, kuten `Func<T, bool>`. `IQueryable<T>` tallentaa lausekepuita (expression trees), esimerkiksi lausekkeen `Expression<Func<T, bool>>`, jotka kyselyntarjoaja (kuten Entity Framework Core) voi kääntää SQL-muotoon (Structured Query Language). `ToList`-metodin kutsuminen liian aikaisin materialisoi tiedot ja siirtää myöhemmän suodatuksen muistiin. Lisäksi kyselyntarjoaja ei pysty kääntämään kaikkia C#-lausekkeita. Vältä `IQueryable`-rajapinnan vuotamista kaikkien sovelluskerrosten läpi, ellei se ole tietoinen suunnitteluvalinta.

IEnumerable<User> loaded = loadedUsers;
var inMemoryAdults = loaded.Where(x => x.Age >= 18);

IQueryable<User> query = dbContext.Users;
var adults = await query
    .Where(x => x.Age >= 18)
    .ToListAsync();
Kokeile vastata tähän kysymykseen tekoälyvalmentajan kanssa

13Mitä viivästetty suoritus tarkoittaa LINQ:ssa (Language Integrated Query)?

Monet LINQ-operaattorit rakentavat kyselyn suorittamatta sitä välittömästi. Suoritus alkaa, kun kysely iteroidaan tai kun kutsutaan päättävää operaatiota, kuten `ToList`, `Count` tai `First`. Tämä tarkoittaa, että lähdetietoihin tehdyt muutokset ennen iterointia voivat vaikuttaa tuloksiin, toistuva iterointi voi toistaa työn tai SQL-kyselyt (Structured Query Language), ja poikkeukset saattavat ilmetä iteroinnin aikana kyselyn rakentamisen sijaan.

var query = numbers.Where(x => x > 10);

numbers.Add(42);

foreach (var number in query)
{
    Console.WriteLine(number);
}

var materialized = query.ToList();
Kokeile vastata tähän kysymykseen tekoälyvalmentajan kanssa

14Miten taulukko, `List<T>`, `Dictionary<TKey,TValue>` ja `HashSet<T>` eroavat toisistaan?

Taulukko on kooltaan kiinteä ja tarjoaa nopean indeksipohjaisen haun. `List<T>` on dynaaminen taulukko, jossa indeksipohjainen haku on O(1). Alkion lisääminen loppuun on amortisoitu O(1), koska kapasiteetin kasvattaminen varaa suuremman taulukon ja kopioi olemassa olevat alkiot, kun taas lisääminen keskelle on O(n). `Dictionary<TKey,TValue>` yhdistää avaimet arvoihin ja tarjoaa yleensä O(1)-aikaisen avainhaun. `HashSet<T>` tallentaa uniikkeja alkioita ja on ihanteellinen jäsenyystarkistuksiin. Tiivisteisiin perustuvissa kokoelmissa on ensiarvoisen tärkeää toteuttaa `Equals`- ja `GetHashCode`-metodit oikein.

var ids = new HashSet<int> { 1, 2, 3 };

if (ids.Contains(userId))
{
    // Fast membership check
}
Kokeile vastata tähän kysymykseen tekoälyvalmentajan kanssa

15Miten IEnumerable<T>, ICollection<T>, IList<T> ja IReadOnlyCollection<T> eroavat toisistaan?

IEnumerable<T> lupaa ainoastaan peräkkäisen läpikäynnin. ICollection<T> lisää Count-ominaisuuden ja muokkausmetodeja, kuten Add ja Remove. IList<T> lisää indeksoidun pääsyn. IReadOnlyCollection<T> tarjoaa läpikäynnin ja Count-ominaisuuden, mutta ei salli muokkauksia kyseisen rajapinnan kautta. API-rajapinnan suunnittelussa tulisi palauttaa suppein hyödyllinen sopimus, mutta vain luku -rajapinnat eivät takaa taustalla olevan olion syvää muuttumattomuutta.

private readonly List<Order> _orders = new();

public IReadOnlyCollection<Order> Orders => _orders;
Kokeile vastata tähän kysymykseen tekoälyvalmentajan kanssa

16Miten `yield return` toimii?

`yield return` luo iteraattorin, jonka suoritus on viivästetty. Kääntäjä muuntaa metodin tilakoneeksi, ja arvot tuotetaan yksi kerrallaan, kun `MoveNext`-metodia kutsutaan. Tämä välttää väliaikaisten kokoelmien luomisen ja toimii hyvin suurilla tietojoukoilla. Tyypillisiä sudenkuoppia ovat elinkaari ja moninkertainen läpikäynti: älä palauta laiskasti suoritettavaa sekvenssiä, joka riippuu jo vapautetusta `DbContext`-oliosta tai tietovirrasta. Muista myös, että `Count`-kutsu ja sitä seuraava `foreach`-silmukka voivat suorittaa iteraattorin kahdesti, mikä toistaa sivuvaikutukset tai I/O-operaatiot.

IEnumerable<int> GetEvenNumbers(IEnumerable<int> source)
{
    foreach (var number in source)
    {
        if (number % 2 == 0)
            yield return number;
    }
}
Kokeile vastata tähän kysymykseen tekoälyvalmentajan kanssa

Delegaatit, tapahtumat ja poikkeukset

17Mitä ovat delegaatit ja lambda-lausekkeet?

Delegaatti on tyyppiturvallinen viittaus metodiin. Lambda-lausekkeet tarjoavat ytimekkäitä sisäkkäisiä funktioita, ja niitä sijoitetaan yleisesti `Func`-, `Action`- tai `Predicate`-delegaateille. Lambda-lausekkeet sieppaavat itse muuttujat, eivät heijastumia niiden arvoista, joten lambda voi havaita silmukan tai muuttujan myöhemmän arvon. Jos lambda ei sieppaa mitään, kääntäjä voi välimuistittaa staattisen delegaatin ja välttää sulkeuman (closure) varaamisen muistista. Suorituskyvyn kannalta kriittisissä koodin osissa sulkeumilla ja pidennetyillä olioiden elinkaarilla voi olla merkitystä.

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;
Kokeile vastata tähän kysymykseen tekoälyvalmentajan kanssa

18Miten `event` eroaa `delegate`-tyypistä?

Tapahtuman (`event`) taustalla on yleensä `delegate`, mutta se rajoittaa sitä, mitä ulkopuolinen koodi voi tehdä. Ulkopuolinen koodi voi tilata (subscribe) tapahtuman ja perua tilauksen (unsubscribe), mutta ainoastaan omistajaluokka voi laukaista sen. Julkinen `delegate`-kenttä sallisi kutsujien korvata koko kutsulistan tai laukaista sen suoraan. Tapahtumat luovat myös viittauksia julkaisijasta tilaajaan, joten pitkäikäiset julkaisijat voivat aiheuttaa muistivuotoja, jos tilaajat eivät peru tilauksiaan.

public event EventHandler<OrderEventArgs>? OrderCreated;

service.OrderCreated += Handler;
service.OrderCreated -= Handler;

OrderCreated?.Invoke(this, args);
Kokeile vastata tähän kysymykseen tekoälyvalmentajan kanssa

19Miten poikkeuksia tulisi käsitellä C#-kielessä?

Käytä poikkeuksia poikkeuksellisiin tilanteisiin, ei normaaliin ohjelmavirran hallintaan. Sieppaa vain ne poikkeukset, jotka pystyt käsittelemään, vältä tyhjiä `catch`-lohkoja, lisää kontekstia kun käärit poikkeuksia uudelleen, äläkä vuoda sisäisiä yksityiskohtia käyttäjille. Käytä `throw;`-lausetta poikkeuksen uudelleennostamiseen, jotta alkuperäinen kutsupino säilyy. Jos käärit poikkeuksen uudelleen, välitä alkuperäinen poikkeus `InnerException`-ominaisuutena, esimerkiksi `throw new DomainException("...", ex)`. Suosi `TryParse`-tyylisiä API-rajapintoja odotettavissa olevissa vikatilanteissa.

try
{
    await service.ProcessAsync();
}
catch (ValidationException exception)
{
    // Expected domain error
}
finally
{
    // Cleanup
}

catch
{
    throw; // preserves stack trace
}
Kokeile vastata tähän kysymykseen tekoälyvalmentajan kanssa

20Mitä ovat poikkeussuodattimet (exception filters)?

Poikkeussuodattimet sallivat `catch`-lohkon suorittamisen vain silloin, kun määritetty lisäehto täyttyy. Suodatin arvioidaan ennen kutsupinon purkamista ja ennen `catch`-lohkoon siirtymistä. Jos suodatin palauttaa epätosi, alkuperäinen kutsupino säilyy koskemattomana diagnostiikkaa ja kaatumisvedoksia varten. Suodattimet ovat hyödyllisiä HTTP (Hypertext Transfer Protocol) -tilakoodien ja tietokannan virhekoodien käsittelyssä, ohimenevien virheiden erottelussa sekä ehdollisessa lokituksessa. Pidä suodattimet yksinkertaisina ja vältä monimutkaisia sivuvaikutuksia.

try
{
    await SendAsync();
}
catch (HttpRequestException ex)
    when (ex.StatusCode == HttpStatusCode.NotFound)
{
    // Handle only 404
}
Kokeile vastata tähän kysymykseen tekoälyvalmentajan kanssa