Keentune

C# curriculum

24 chapters
·
211 concepts
·
free
Everything the adaptive question bank can teach and test in C#, from foundations through advanced practice. Work through it in order, or start practising and let the questions find your level.
New here? Read the C# guide
A free 18-minute primer — the mental model, the mistakes beginners make, and what to practise first.
A. The type system: value types, reference types, boxing
a value-type variable holds the data; a reference-type variable holds a reference to it
assigning a struct copies every field; assigning a class copies only the reference
where a value lives follows containment (field of a class, captured local), not the value/reference keyword
value types default to all-fields-zero, reference types to null; default(T) names it
converting a value type to object or an interface allocates a heap copy
a boxed int will not unbox to long; you get InvalidCastException
boxing inside a loop allocates once per iteration and erases the reason you chose a struct
var infers the compile-time type; it is not dynamic and the type never changes
dynamic moves member resolution to runtime and gives up every compile-time check
B. Numbers and arithmetic
int / int discards the fraction; cast one operand to get a fractional result
integer arithmetic wraps silently unless a checked context is active
the checked operator and statement turn wraparound into OverflowException
two binary approximations versus an exact base-10 type with ~28 significant digits
decimal avoids the representation error that makes 0.1 + 0.2 != 0.3 in double
comparing double with == is a bug; compare within a tolerance
double.NaN != double.NaN, so only double.IsNaN detects it; division by zero differs for double and int
byte + byte evaluates as int, so assigning the result back needs a cast
widening is implicit; narrowing needs a cast and may silently lose data
C. Strings and text
every "modification" allocates a new string; the original is untouched
concatenating in a loop is quadratic allocation; StringBuilder is the fix
identical literals share one instance, which makes reference equality accidentally "work"
== on string compares characters, unlike every other reference type
StringComparison.Ordinal compares code units; culture rules differ per locale and per OS
$"..." evaluates each hole at runtime and applies its format specifier
@"..." keeps backslashes; """...""" keeps newlines and quotes without escaping
char is 16 bits, so an emoji is two chars and Length is not a count of characters
ToString and parsing follow CurrentCulture unless you pass InvariantCulture
D. Null: nullable value types and nullable reference types
int? is Nullable<int>, a struct with HasValue and Value — still a value type
reading .Value on an empty nullable throws InvalidOperationException, not a null reference
an operator on nullables propagates null: any null operand makes the whole result null
with a null operand both < and > are false, so !(a < b) is not a >= b
nullable reference annotations are static analysis; nothing is checked or enforced at runtime
string? changes no IL; it changes what the analyzer warns about
the ! suffix silences the warning and asserts nothing; it is a promise, not a check
?. short-circuits the entire chain and yields a nullable result
?? supplies a fallback; ??= assigns only when the target is null
MaybeNull, NotNullWhen and friends describe flow the compiler cannot infer
ArgumentNullException.ThrowIfNull on a public boundary, because callers may not be nullable-aware
E. Structs and value semantics
a struct passed by value is copied; mutating the parameter cannot be seen by the caller
calling a mutating method on a struct held in a readonly field or a collection mutates a hidden copy
readonly struct promises no member mutates, letting the compiler skip defensive copies
default(T) and new T[n] zero the fields without running any constructor
ValueType.Equals falls back to reflection; override Equals and GetHashCode
implementing IEquatable<T> lets generic code compare without boxing each operand
in passes a large struct by read-only reference instead of copying it
a struct cannot inherit or be inherited from; reuse comes from interfaces and composition
F. Records and immutable data
a record's generated Equals compares every field, not the reference
record alone is a reference type; record struct is a value type with the same generated members
with copies the instance and replaces only the named members
a positional record generates init-only properties, a constructor, and Deconstruct
init allows assignment during object initialization and never afterwards
required forces the caller to set a member without adding a constructor parameter
a base and a derived record are never equal, because the generated EqualityContract differs
the printed form lists the type name and every property, which makes records good log payloads
G. Members: fields, properties, constructors, indexers
the compiler emits a hidden field; a property is still a pair of methods, not storage
a property can be virtual, appear on an interface, and change implementation without breaking callers
=> on a property recomputes on every read; it is not a cached value
: this(...) and : base(...), and where field initializers run in that order
runs once, lazily, before first use, and cannot be called or ordered by you
const is inlined at compile time; readonly is assigned at construction and can differ per instance
changing a public const requires recompiling every consumer, because the old value is baked in
this[...] gives a type array-like access and can be overloaded by key type
H. Inheritance and polymorphism
a virtual member dispatches on the object's runtime type
a hidden member dispatches on the compile-time type, so the same object behaves differently through two references
an abstract member has no body and obliges every concrete derived class to supply one
sealed override ends the chain and lets the JIT devirtualize the call
base.M() invokes the parent implementation directly, with no further virtual dispatch
the base constructor runs first, so a virtual call reaches a derived object whose fields are still unset
override both or dictionaries break; the hash must not change while the object is a key
a boolean test, a null-returning conversion, and a throwing conversion
private, protected, internal, protected internal (either) and private protected (both)
I. Interfaces
a public member satisfies the interface and stays callable on the concrete type
an explicitly implemented member is reachable only through an interface-typed reference
two interfaces declaring the same signature are separated by implementing each explicitly
an interface member with a body is inherited but callable only through the interface
interfaces hold no fields, so a default member can only call other members
an interface can require a static member, which is what makes generic math possible
many contracts versus one implementation inheritance, and the versioning cost of each
J. Methods, parameters and overloads
passing a class copies the reference, so reassigning the parameter is invisible to the caller
ref aliases the caller's variable, so an assignment inside the method is visible outside
out needs no prior value but must be definitely assigned before the method returns
a variadic call allocates an array, and an empty call still passes one
the default value is compiled into the call site, so changing it needs every caller recompiled
the best conversion wins; a genuine tie is a compile error, never a runtime choice
a static method bound by its first parameter, which always loses to an applicable instance method
a nested function that can recurse and capture without allocating a delegate
returning a reference into an array or field, and the escape rules that stop it outliving its storage
K. Operators and expressions
multiplicative before additive, relational before equality, assignment last and right-associative
&& and || skip the right operand; & and | always evaluate both
both arms must convert to one common type, and the result is an expression, not a branch
the value of the expression differs from the effect on the variable
x += y inserts an implicit cast back to x's type, hiding a narrowing conversion
overloading == obliges you to overload !=, and to reconsider Equals and GetHashCode
implicit and explicit conversion operators, and why an implicit one surprises readers
a compile-time string and a compile-time Type, both resolved without executing anything
is null uses the pattern, so an overloaded == cannot intercept it
L. Statements and control flow
one is a statement with cases, the other an expression that produces a value
every non-empty switch section must break, return, throw or goto
an unmatched value throws SwitchExpressionException; the compiler only warns
any type with a suitable GetEnumerator works; implementing IEnumerable is not required
you cannot assign to the foreach variable, and mutating the collection invalidates the enumerator
a local must be provably assigned on every path before it is read
using var x = ... disposes at end of scope without another level of nesting
M. Pattern matching
is Foo f tests the type and introduces the typed variable in one step
matching a literal, and is null versus is not null
is > 0 and < 10, with not binding tightest and or loosest
matching nested members directly, without a chain of null checks
a tuple-shaped pattern requires a tuple or a Deconstruct method
matching a sequence's shape, with .. for the unexamined middle
always matches and binds, which is how you name a computed value for a guard
an extra boolean tested only after the pattern itself matches
the first matching arm wins, so a broader pattern above makes a narrower one unreachable
N. Exceptions
entering a try is nearly free; throwing and walking the stack is not
a base-type catch placed above a derived one is a compile error
on normal exit, on return, and while an exception unwinds
a bare throw preserves the original stack trace; throw ex resets it to here
when inspects the exception before the stack unwinds, so a logging filter sees the original frames
wrapping preserves the cause; diagnosis means walking InnerException
Wait and .Result wrap failures in one; await rethrows only the first
derive from Exception, keep the standard constructors, and carry data as properties
an empty catch (Exception) converts a crash into corrupted state you find much later
O. Generics
generics give compile-time type safety and skip the boxing that an object-based API forces
where T : class, struct, new(), a base type, or an interface
the newer constraints, and exactly which types each admits
each value-type instantiation gets its own JIT-compiled code; reference types share one
List<> versus List<int>, and what typeof returns for each
inferred from the arguments only; the return type never participates
Cache<int> and Cache<string> hold entirely separate static fields
default(T) is null for a reference type and a zeroed value for a struct, which is why == null is the wrong test
with no constraint, only object's members are available on T
P. Variance
IEnumerable<Derived> is an IEnumerable<Base> because T only comes out
Action<Base> is an Action<Derived> because T only goes in
List<Derived> is not a List<Base>, and writing to it is why
arrays are covariant at compile time and throw ArrayTypeMismatchException on the store
classes cannot declare in or out type parameters
IEnumerable<int> is not IEnumerable<object>, because boxing is not a reference conversion
Q. Delegates, lambdas and events
the declaration defines a signature; an instance holds a target object and a method
a combined delegate invokes in order and returns only the last result
the built-in generic delegates, and which of them returns a value
a lambda captures the variable itself, not its value at the moment of capture
the foreach variable is fresh per iteration; a for variable is shared by every lambda
the compiler lifts them into a display class, which is what keeps them alive
Expression<Func<...>> compiles to inspectable data a provider can translate, not to executable IL
the event keyword limits outside code to += and -=
a publisher's handler list keeps every subscriber alive until it unsubscribes
R. Collections
Length never changes; Array.Resize allocates and copies a new array
Count is elements, Capacity is allocated slots, and growth reallocates by doubling
a key whose GetHashCode changes after insertion becomes unreachable
the indexer throws on a missing key; TryGetValue reports it without an exception
set semantics with amortized O(1) containment, and the in-place set operations
FIFO, LIFO, and O(1) insertion at a node you already hold
throws InvalidOperationException; iterate a snapshot or collect the changes
what IEnumerable, ICollection, IList and IReadOnlyList each add to the contract
S. Iterators
the compiler rewrites the method into a class whose MoveNext resumes where it left off
nothing in the method executes until the first MoveNext, so argument checks are delayed too
split into a normal method that validates and a private iterator, so guards throw at call time
ends the sequence without producing a value
a finally inside an iterator runs when the enumerator is disposed, which foreach does on exit
enumerating the same query twice executes the body twice
await foreach over an async sequence, each element awaited as it arrives
T. LINQ
building a query executes nothing; the work happens when it is enumerated
changing a captured variable after composing the query changes the result you get later
ToList, ToArray, Count, First and the aggregates run the query on the spot
Where and Select stream one element at a time; OrderBy and GroupBy must read the whole source first
the from/where/select form compiles to exactly the same method calls
a one-to-one projection versus flattening a sequence of sequences
throwing on an empty sequence versus returning the element type's default
Single throws when a second match exists, which First happily ignores
Any stops at the first match; Count() > 0 walks the entire sequence
a second OrderBy replaces the ordering; ThenBy refines it
one builds an expression tree for a remote provider, the other runs delegates in memory
mutating the source between composing and enumerating changes or invalidates the results
U. Asynchronous programming
await releases the current thread while waiting; it does not start a new one
the compiler rewrites everything after each await into a continuation on a state machine
an async void method's exception cannot be awaited or caught by the caller; it crashes the process
the Task an async method returns is already running; awaiting only observes it
.Result or .Wait() on a captured context blocks the very thread the continuation needs
the captured context is what makes the continuation resume on the UI thread
declines to capture the context; the default choice inside a library
the exception is stored and rethrown at the await, not at the call
a Task that is never awaited can swallow its failure entirely
start both operations, then await, instead of awaiting each in turn
awaiting WhenAll surfaces only the first exception; the rest are on the returned Task
avoids an allocation on the usually-synchronous path, and may be awaited only once
cancellation is cooperative: the callee must observe the token or pass it further down
bridges a callback-based API into a Task, with RunContinuationsAsynchronously to avoid inline resumption
V. Resource management and the garbage collector
liveness is reachability from roots, which is why reference cycles are collected
most objects die young, so gen0 collections are frequent and cheap and gen2 is not
objects above the LOH threshold are allocated separately and are not compacted by default
a finalizer may run late or never, and it keeps the object alive one extra collection
deterministic release of a resource the collector knows nothing about
Dispose(bool disposing) plus GC.SuppressFinalize, for a type that owns an unmanaged handle
the using statement compiles to try/finally, so an exception cannot skip cleanup
wraps an OS handle so the runtime cannot leak it across a P/Invoke or an abort
static collections, event subscriptions and captured closures keep objects reachable forever
W. Spans, memory and allocation
Span<T> points into existing memory — array, stack or native — without copying it
a ref struct cannot be boxed, stored in a field, or captured by a lambda
the stack-only rule is exactly why an async method cannot hold a Span<T> across a suspension
the heap-storable counterpart that async code can hold, with .Span to get the view
AsSpan() slices with no allocation where Substring allocates a new string
a small buffer allocated on the stack, and why an unbounded size is a stack-overflow bug
.. and ^ produce a copy from an array but a view from a span
renting and returning buffers to remove steady-state allocation from a hot path
X. Reflection, attributes and unsafe code
an attribute changes nothing until some code reflects over it
declares where an attribute may appear, whether it repeats, and whether it is inherited
CallerMemberName and friends are filled in by the compiler at the call site, not by reflection
member lookup and Invoke are orders of magnitude slower than a direct call; cache or emit a delegate
the compile-time type named in source versus the runtime type of an instance
MakeGenericType and MakeGenericMethod close an open type at runtime
compile-time code generation, which removes the reflection cost and works with trimming and AOT
pointers require an unsafe context, and fixed pins an object so the GC cannot move it
Keentune is not affiliated with or endorsed by the organizations whose documentation informs these maps.
All about C# practice
Also on your phone
All exam, test, and product names and trademarks are the property of their respective owners and are used here for identification and reference only. Keentune is independent study practice — not affiliated with, authorized, or endorsed by any of these organizations.
© 2026 SportaApp LLC