02.2 - OOP 2: Records, Nullable, Delegates
Recap
In 02.1 - OOP 1 we built GameConfiguration, GameState and GameBrain as classes and put a human and an AI behind the same IMoveProvider interface. This lecture covers the other half of the type system: values that may be missing (nullable), types that just carry data (struct, record, tuple, enum) and types that carry behaviour (delegates, lambdas, events) — the glue our MenuSystem is built from.
By the end of this lecture you should be able to:
- Write nullable-aware code with
int?,string?,?.,?[],??,??=and!, and initialise members so the compiler stays happy. - Choose between
class,struct,record,record structand a tuple, and explain boxing. - Define
GameConfigurationas a record and create modified copies withwith. - Pass behaviour around with
Func,Action,Predicateand lambdas, and explain what a closure captures. - Wire
MenuItemactions and subscribe to aMoveMadeevent.
Lecture demos: csharp-2026-fall
Nullable value types
A value type always has a value — bool is true or false, int is some number. Sometimes the honest answer is "not known yet": a switch nobody has looked at is neither on nor off, a win length nobody has chosen is not 0. Append ? to the type. int? is shorthand for the struct Nullable<int>: the value plus a HasValue flag.
bool? switchState = null; // not observed yet
int? winLength = ReadOptionalNumber(); // null when the user typed nothing
if (winLength.HasValue) Console.WriteLine(winLength.Value); // .Value throws when null
var effective = winLength.GetValueOrDefault(3); // 3 when null
var alsoEffective = winLength ?? 3; // the usual way
if (winLength is int n && n >= 3) Console.WriteLine(n); // pattern matching unwraps it
In the game, EGamePiece? Winner on GameState is a better "no winner yet" than reusing EGamePiece.Empty, which already means an empty cell.
Nullable reference types
Reference types (string, arrays, every class) have always been able to hold null, and NullReferenceException has always been the number one runtime error. Since C# 8 the compiler tracks nullability of references statically:
string name— must never benull. The compiler checks that it is initialised and never assignednull.string? nickname— may benull. The compiler refuses to dereference it until it has seen a null check (flow analysis).
string name = null; // CS8625 — an error in our setup
string? nickname = null; // fine
Console.WriteLine(nickname.Length); // CS8602 — possible null dereference
if (nickname is not null) Console.WriteLine(nickname.Length); // ok: flow analysis
if (!string.IsNullOrWhiteSpace(nickname)) Console.WriteLine(nickname.Length); // ok: the BCL method is annotated
This is a compile-time feature only. At run time every reference can still be null — nothing is checked, no extra code is emitted. Data from outside (JSON, database, user input) is the usual way null sneaks into a "non-nullable" variable. The Directory.Build.props that turns these warnings into errors is described in 01.1 - Course Intro & Tooling.
The null operators
| Operator | Name | Meaning |
|---|---|---|
a ? b : c | conditional | Not about null at all — listed because it looks like the others. |
x?.Member | null-conditional | If x is null the whole chain is null; otherwise access the member. |
x?[i] | null-conditional index | Same, for indexers. |
x ?? y | null-coalescing | x if it is not null, otherwise y. |
x ??= y | null-coalescing assignment | Assign y to x only if x is null. |
x! | null-forgiving | "Trust me, it is not null." Silences the compiler, changes nothing at run time. |
GameState? state = repository.Load(id); // may return null
int? width = state?.Config.BoardWidth; // null if state is null — ?. short-circuits the rest of the chain
EGamePiece? cell = state?.Board[0][0]; // the whole chain becomes nullable
var title = state?.Config.Name ?? "no game loaded";
var firstRow = state?.Board?[0]; // ?[] on the array — EGamePiece[]? here
cached ??= brain.State; // cached is GameState? — fill only if still null
var name = state!.Config.Name; // NullReferenceException at run time if state is null
! is not a fix, it is a promise. Use it where you know better than the compiler (right after a check it cannot see) and nowhere else. A ! on a value that came from JSON or a database is a bug waiting to happen.
Late initialisation
A non-nullable member must be initialised before the constructor finishes, otherwise CS8618. Pick the pattern that says what you mean:
public class GameState
{
// 1. caller must set it — best for data objects
public required GameConfiguration Config { get; init; }
// 2. sensible default in the initialiser
public EGamePiece[][] Board { get; set; } = [];
// 3. assigned in the constructor
public string Name { get; set; }
public GameState(string name) => Name = name;
// 4. last resort: "somebody else sets it before use" (a deserializer, EF Core)
public string SavedBy { get; set; } = default!;
}
A field filled by a helper method needs [MemberNotNull] so that flow analysis believes it:
private GameState? _state;
public GameState State => _state ?? throw new InvalidOperationException("Call NewGame first");
[MemberNotNull(nameof(_state))]
public void NewGame(GameConfiguration config) => _state = CreateEmptyState(config);
Nullable attributes
The attributes in System.Diagnostics.CodeAnalysis describe what the type syntax cannot. You will read them in the BCL far more often than you write them.
| Attribute | Meaning |
|---|---|
[AllowNull] | Non-nullable input accepts null (a setter normalises it). |
[DisallowNull] | Nullable input must not be set to null. |
[MaybeNull] | Non-nullable output may actually be null. |
[NotNull] | Nullable output (return, ref, out) is not null when the method returns. |
[NotNullWhen(bool)] | Nullable argument is not null when the method returns the given bool. |
[MemberNotNull("f")] | After the method returns, member f is not null. |
[DoesNotReturn] | Always throws — code after the call is unreachable. |
The TryParse pattern for our own types:
public static bool TryLoad(string path, [NotNullWhen(true)] out GameState? state)
{
state = File.Exists(path) ? Deserialize(path) : null;
return state is not null;
}
if (TryLoad("game.json", out var loaded)) Console.WriteLine(loaded.Config.Name); // no warning
Struct
A struct is a value type: assigned by copy, stored inline in its container (stack, array element, another object) rather than behind a reference. A class is a reference type: variables hold a pointer and assignment copies the pointer.
public readonly struct BoardPosition(int row, int col)
{
public int Row { get; } = row;
public int Col { get; } = col;
}
var a = new BoardPosition(1, 2);
var b = a; // copy — b is independent of a
- No inheritance (cannot be a base, cannot derive), but a struct can implement interfaces.
default(BoardPosition)is all zeros — always valid, never null; field initialisers and a parameterless constructor (C# 10+) are bypassed bydefault.readonly structforbids mutation; prefer it.
Use a struct only when all of these hold: it represents a single value (a position, a colour, an amount); it is small (16 bytes or less as a rule of thumb); it is immutable; it will not be boxed frequently. Otherwise use a class — or, for data, a record.
Record
A record is a reference type built for data: the compiler generates value equality, a readable ToString(), deconstruction and the with expression. Positional syntax declares the properties, a constructor and init accessors in one line:
public record GameConfiguration(string Name, int BoardWidth, int BoardHeight, int WinLength)
{
public static GameConfiguration TicTacToe => new("Tic-Tac-Toe", 3, 3, 3);
public int CellCount => BoardWidth * BoardHeight;
}
var a = new GameConfiguration("Tic-Tac-Toe", 3, 3, 3);
var b = GameConfiguration.TicTacToe;
Console.WriteLine(a); // GameConfiguration { Name = Tic-Tac-Toe, BoardWidth = 3, ... }
Console.WriteLine(a == b); // True — value equality member by member, although they are two objects
var (name, width, height, _) = a; // deconstruction, discard the last item
with — non-destructive mutation
Records are immutable by default (init setters). To "change" one you create a copy with some members replaced:
var big = a with { BoardWidth = 10, BoardHeight = 10, WinLength = 5 };
Console.WriteLine(a.BoardWidth); // 3 — a is untouched
Console.WriteLine(big.BoardWidth); // 10
This is exactly what the options menu will do: the current configuration is never edited in place, a new one replaces it. Immutable configurations are safe to share, to cache and to use as dictionary keys.
- A record can also be declared with explicit properties (
public required string Name { get; init; }) — same generated behaviour. Records can inherit from records, not from classes;sealed recordis a good default. record structgives the same features as a value type:public readonly record struct BoardPosition(int Row, int Col);replaces the whole struct above.- Value equality compares members with their own
Equals. An array member compares by reference, so two records holding equal-lookingEGamePiece[][]boards are not equal. Records are for simple data (configuration, DTOs); the mutable board stays in theGameStateclass.
| You need... | Use |
|---|---|
| Data compared by value, a few fields, immutable | record |
| The same, but tiny and created in hot loops | readonly record struct |
| Identity, mutable state, behaviour, inheritance | class |
| Two or three values returned from a private method | tuple |
Boxing
Boxing converts a value type into an object (or an interface it implements) by allocating a copy on the heap. Unboxing casts it back. Boxing is implicit, unboxing is explicit.
int i = 123;
object o = i; // boxing — new heap object holding 123
int j = (int)o; // unboxing — copy back out
Boxing allocates. It hides in object parameters, in old non-generic collections, in string.Format arguments and in calling interface methods on a struct through the interface type. A struct that is boxed on every use loses its only advantage — one more reason for the "will not be boxed frequently" rule above.
Tuples
A tuple is a lightweight, unnamed value type bundling a few values. We already use one: a move is (int row, int col).
var move = (row: 1, col: 2); // named elements, move.row == 1
(int row, int col) next = provider.GetMove(state);
var (r, _) = next; // deconstruction, discard the column with _
(int row, int col) Center(GameConfiguration cfg) => (cfg.BoardHeight / 2, cfg.BoardWidth / 2);
Tuples are for returning two or three values from a private or internal method. If the values travel further — across projects, into JSON, into a public API — name them: write a record. (int row, int col) inside the engine is fine; GameConfiguration as a 4-tuple would not be.
Enums
An enum is a set of named integer constants. The underlying type is int by default and numbering starts at 0 — which is why Empty comes first in EGamePiece: a freshly created EGamePiece[] is all Empty. EMenuLevel { Main, Second, Deeper } follows the same pattern.
public enum EGamePiece { Empty, X, O } // Empty = 0, X = 1, O = 2
var piece = EGamePiece.X;
Console.WriteLine(piece); // X — ToString gives the name
Console.WriteLine((int)piece); // 1 — explicit cast to the number
var fromNumber = (EGamePiece)2; // O
var nonsense = (EGamePiece)42; // compiles! no validation — guard with Enum.IsDefined
foreach (var p in Enum.GetValues<EGamePiece>()) Console.WriteLine($"{(int)p}: {p}"); // 0: Empty, 1: X, 2: O
if (Enum.TryParse<EGamePiece>("o", ignoreCase: true, out var parsed)) { /* parsed == EGamePiece.O */ }
var symbol = piece switch { EGamePiece.X => "X", EGamePiece.O => "O", _ => "." }; // switch expression
- Prefix enums with
Ein this course (EGamePiece,EMenuLevel) so they are easy to spot; never use an enum where aboolis meant. [Flags]enums combine powers of two with|(FileAccess.Read | FileAccess.Write) — for permissions and options, not for game pieces.- By default enums serialise to JSON as numbers, which breaks the moment you reorder members; 04.1 - JSON shows how to store them as strings.
Delegates
A delegate is a type that describes a method signature. A variable of that type holds a reference to any method with a compatible signature, and you call the method through the variable. This is how behaviour is passed as an argument.
public delegate string MenuAction(); // "a method that takes nothing and returns a string"
string StartNewGame() { /* ... */ return "game over"; }
MenuAction action = StartNewGame; // method group -> delegate
var result = action(); // call through the delegate
Delegates are multicast: += adds a second method and invoking the delegate calls both, in order. That is the basis for events.
Func, Action, Predicate
You almost never declare your own delegate types — the BCL has generic ones for every shape, with 0 to 16 parameters:
| Delegate | Returns | Example |
|---|---|---|
Action<T1, ...> | void | Action<GameState> print = BoardPrinter.Print; |
Func<T1, ..., TResult> | TResult — always the last type argument | Func<int, int, string> size = (w, h) => $"{w}x{h}"; |
Predicate<T> | bool | Predicate<MenuItem> isNew = i => i.Shortcut == "N"; |
List<T>.Find takes a Predicate<T>, LINQ takes Func<T, bool> — the same idea under two names for historical reasons.
Lambdas
A lambda is an anonymous method written inline: parameters => body. It is converted to whatever delegate type the context expects.
// expression lambda — the body is one expression, its value is returned
Func<string> label = () => "New game";
Func<int, int> square = x => x * x;
// statement lambda — a block with statements and an explicit return
Func<string> askName = () =>
{
Console.Write("Your name: ");
var input = Console.ReadLine();
return string.IsNullOrWhiteSpace(input) ? "Anonymous" : input.Trim();
};
Parameter types are inferred from the delegate type. Write them out ((int x) => ...) only when inference fails, for example when assigning to var.
Closures
A lambda may use variables of the enclosing method. It captures the variable, not its value at creation time — later changes are visible inside the lambda, and changes made by the lambda are visible outside.
var config = GameConfiguration.TicTacToe;
Func<string> widthLabel = () => $"Board width: {config.BoardWidth}";
Console.WriteLine(widthLabel()); // Board width: 3
config = config with { BoardWidth = 10 };
Console.WriteLine(widthLabel()); // Board width: 10 — same variable, new value
That is exactly what a self-updating menu label needs.
Pitfall: capturing a for loop variable
A for loop has one variable that every iteration reuses, so every lambda captures the same one:
List<Func<string>> labels = [];
for (var i = 0; i < 3; i++)
{
labels.Add(() => $"item {i}");
}
foreach (var label in labels) Console.WriteLine(label()); // item 3, item 3, item 3
Fix: declare var index = i; inside the loop body and capture index — it is a fresh variable per iteration, so you get item 0, 1, 2. A foreach variable is already fresh each round; only for bites.
The menu — behaviour as data
MenuItem stores what to do as a Func<string>; the action returns a string telling the menu what happened. An optional TitleProvider recomputes the label every time the menu is drawn.
public class MenuItem
{
public required string Shortcut { get; init; }
public required string Title { get; init; }
public Func<string>? TitleProvider { get; init; }
public required Func<string> Action { get; init; }
public string GetTitle() => TitleProvider?.Invoke() ?? Title;
}
public class Menu(string title, EMenuLevel level)
{
private readonly List<MenuItem> _items = [];
public Menu AddItem(MenuItem item) { _items.Add(item); return this; } // fluent: .AddItem(...).AddItem(...)
public string Run()
{
while (true)
{
Console.WriteLine($"=== {title} ===");
foreach (var item in _items) Console.WriteLine($"{item.Shortcut}) {item.GetTitle()}");
if (level != EMenuLevel.Main) Console.WriteLine("R) Return");
Console.WriteLine("X) Exit");
var input = (Console.ReadLine() ?? "").Trim().ToUpperInvariant();
if (input == "X") return "X";
if (input == "R" && level != EMenuLevel.Main) return "R";
var chosen = _items.Find(i => i.Shortcut.ToUpperInvariant() == input); // Predicate<MenuItem>
if (chosen is null) { Console.WriteLine("Unknown choice."); continue; }
var result = chosen.Action();
if (result == "X") return "X"; // "exit" bubbles up from a deeper menu
}
}
}
Wiring it up in ConsoleUI: closures make the current config visible to both the label and the action, and the record's with replaces it.
var config = GameConfiguration.TicTacToe;
var optionsMenu = new Menu("Options", EMenuLevel.Second)
.AddItem(new MenuItem
{
Shortcut = "W",
Title = "Board width",
TitleProvider = () => $"Board width: {config.BoardWidth}",
Action = () =>
{
Console.Write("New width: ");
if (int.TryParse(Console.ReadLine(), out var width) && width >= GameBrain.MinBoardSize)
{
config = config with { BoardWidth = width };
}
return "";
},
});
var mainMenu = new Menu("N in a row", EMenuLevel.Main)
.AddItem(new MenuItem { Shortcut = "N", Title = "New game", Action = () => GameRunner.Play(config) })
.AddItem(new MenuItem { Shortcut = "O", Title = "Options", Action = optionsMenu.Run });
mainMenu.Run();
Action = optionsMenu.Run is a method group — no lambda needed when the signature already matches. GameRunner.Play is last lecture's game loop moved into a static method that returns "" when the game ends normally or "X" if the player wants to quit the whole program.
Events
An event is a delegate field that outsiders may only subscribe to (+=) and unsubscribe from (-=). They cannot invoke it or overwrite the subscriber list; only the declaring class raises it.
public class GameBrain(GameConfiguration config)
{
public event Action<GameState>? MoveMade;
public bool MakeMove(int row, int col)
{
// ... validate and place the piece ...
MoveMade?.Invoke(State); // null when nobody has subscribed
return true;
}
}
var brain = new GameBrain(config);
brain.MoveMade += BoardPrinter.Print; // method group
brain.MoveMade += state => Console.WriteLine($"Next: {state.NextMoveBy}");
void Autosave(GameState state) => repository.Save(state);
brain.MoveMade += Autosave;
brain.MoveMade -= Autosave; // unsubscribe with the same method group — an inline lambda cannot be removed this way
The engine does not know that a printer or a repository exists — it just announces. The BCL convention is EventHandler<TEventArgs> with an (object? sender, TEventArgs e) signature; a plain Action<T> is fine for our own code. Remember that a subscribed handler keeps its subscriber alive as long as the publisher lives — a long-lived publisher plus a forgotten -= is the classic .NET memory leak.
Self preparation QA
Be prepared to explain topics like these:
- What is the difference between
int?andstring?? —int?is a different type (Nullable<int>, a struct withHasValue);string?is the samestringat run time with a compile-time annotation that null is allowed. The first changes the data, the second only what the compiler checks. - What does
state?.Config.Name ?? "none"do? — Ifstateis null the whole?.chain evaluates to null without touchingConfig;??then supplies"none". No exception in either case. - When is
= default!acceptable? — Only when something outside the constructor is guaranteed to set the member before use (a deserializer, EF Core). For your own data objects preferrequired, an initialiser or a constructor. - Why make
GameConfigurationa record andGameStatea class? — Configuration is small immutable data compared by value and copied withwith; the board is a mutable array with identity, and record value equality would compare that array by reference anyway. - What does a lambda capture — the value or the variable? — The variable. Reassigning it later changes what the lambda sees, and the lambda's own assignments are visible outside. That is why a
forloop variable must be copied to a local before capture. - What is the difference between
Func<string>,Action<string>andPredicate<string>? —Func<string>takes nothing and returns a string;Action<string>takes a string and returns nothing;Predicate<string>takes a string and returnsbool. The last type argument ofFuncis always the return type. - Why declare
MoveMadewitheventinstead of as a plainAction<GameState>?property? —eventlimits outside code to+=and-=: nobody can raise it from outside or wipe the subscriber list by assignment. OnlyGameBraininvokes it. - What happens when you box a struct? — A copy is allocated on the heap and an
objectreference points to it; changes to the box do not affect the original. It costs an allocation, which is why small hot-path structs should stay away fromobject-typed APIs.