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.