Skip to main content

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 struct and a tuple, and explain boxing.
  • Define GameConfiguration as a record and create modified copies with with.
  • Pass behaviour around with Func, Action, Predicate and lambdas, and explain what a closure captures.
  • Wire MenuItem actions and subscribe to a MoveMade event.
Demo code

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 be null. The compiler checks that it is initialised and never assigned null.
  • string? nickname — may be null. 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
warning

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​

OperatorNameMeaning
a ? b : cconditionalNot about null at all — listed because it looks like the others.
x?.Membernull-conditionalIf x is null the whole chain is null; otherwise access the member.
x?[i]null-conditional indexSame, for indexers.
x ?? ynull-coalescingx if it is not null, otherwise y.
x ??= ynull-coalescing assignmentAssign 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
danger

! 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.

AttributeMeaning
[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 by default.
  • readonly struct forbids 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 record is a good default.
  • record struct gives 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-looking EGamePiece[][] boards are not equal. Records are for simple data (configuration, DTOs); the mutable board stays in the GameState class.
You need...Use
Data compared by value, a few fields, immutablerecord
The same, but tiny and created in hot loopsreadonly record struct
Identity, mutable state, behaviour, inheritanceclass
Two or three values returned from a private methodtuple

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 E in this course (EGamePiece, EMenuLevel) so they are easy to spot; never use an enum where a bool is 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:

DelegateReturnsExample
Action<T1, ...>voidAction<GameState> print = BoardPrinter.Print;
Func<T1, ..., TResult>TResult — always the last type argumentFunc<int, int, string> size = (w, h) => $"{w}x{h}";
Predicate<T>boolPredicate<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:

  1. What is the difference between int? and string?? — int? is a different type (Nullable<int>, a struct with HasValue); string? is the same string at run time with a compile-time annotation that null is allowed. The first changes the data, the second only what the compiler checks.
  2. What does state?.Config.Name ?? "none" do? — If state is null the whole ?. chain evaluates to null without touching Config; ?? then supplies "none". No exception in either case.
  3. 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 prefer required, an initialiser or a constructor.
  4. Why make GameConfiguration a record and GameState a class? — Configuration is small immutable data compared by value and copied with with; the board is a mutable array with identity, and record value equality would compare that array by reference anyway.
  5. 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 for loop variable must be copied to a local before capture.
  6. What is the difference between Func<string>, Action<string> and Predicate<string>? — Func<string> takes nothing and returns a string; Action<string> takes a string and returns nothing; Predicate<string> takes a string and returns bool. The last type argument of Func is always the return type.
  7. Why declare MoveMade with event instead of as a plain Action<GameState>? property? — event limits outside code to += and -=: nobody can raise it from outside or wipe the subscriber list by assignment. Only GameBrain invokes it.
  8. What happens when you box a struct? — A copy is allocated on the heap and an object reference 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 from object-typed APIs.