Module 2 · 2. Type System, Values, Nullability, and Pattern Matching · Lesson 4 of 24
Value Types, Reference Types, and Nullability Without Myths
Learning outcomes
Trace what an assignment copies, distinguish mutation from reassignment, and explain boxing without a stack-versus-heap shortcut. Then turn an optional delivery address into a checked non-null value for downstream code.
The four C# blocks are four independent .NET 10 console programs. Create a separate console project for each, replace Program.cs with one block, and run from its project directory. Enable nullable analysis with <Nullable>enable</Nullable> in the project file. Do not concatenate the programs or paste output blocks as source.
Start with one trace
For a first pass, read the assignment section and predict the CopyAndAlias output before checking it. Focus on three actions: copying a stamp, changing a shared ticket, and pointing a variable at a different ticket.
The later boxing and delivery-address examples are optional deeper practice on this first pass. Keep them for a second session if the first trace is still new; all four complete programs and their run instructions remain available below.
Assignment: copy a value or copy an object reference
For ordinary assignment, a value-type variable supplies its value; a reference-type variable supplies an object reference. Copying a reference can leave two variables referring to one object. The examples here use ordinary variables and fields, not ref aliases or ref/out parameters. See the C# type-system specification.
In CopyAndAlias, AttemptStamp contains one integer. First, copy it and change the copy. Next, put an AttemptStamp field inside WorkTicket, copy that ticket reference, and change the shared ticket through the second variable. Finally, point the second variable at a different ticket.
using System;
var first = new AttemptStamp { Count = 2 };
var copied = first;
copied.Count++;
Console.WriteLine($"struct fields: first={first.Count}; copied={copied.Count}");
var ticket = new WorkTicket { Attempts = new AttemptStamp { Count = 2 } };
var alias = ticket;
alias.Attempts.Count++;
Console.WriteLine($"shared ticket: original={ticket.Attempts.Count}; alias={alias.Attempts.Count}");
Console.WriteLine($"same ticket: {ReferenceEquals(ticket, alias)}");
alias = new WorkTicket { Attempts = new AttemptStamp { Count = 9 } };
Console.WriteLine($"after reassign: original={ticket.Attempts.Count}; alias={alias.Attempts.Count}");
Console.WriteLine($"same ticket after reassign: {ReferenceEquals(ticket, alias)}");
public struct AttemptStamp
{
public int Count;
}
public sealed class WorkTicket
{
public AttemptStamp Attempts;
}Expected output:
struct fields: first=2; copied=3 shared ticket: original=3; alias=3 same ticket: True after reassign: original=3; alias=9 same ticket after reassign: False
copied.Count++changes the copied stamp from 2 to 3;first.Countremains 2.
alias.Attempts.Count++changes the Attempts field in the one ticket reached by both variables. Both reads now produce 3, and ReferenceEquals reports True.
- Assigning a new ticket to
aliaschanges that variable's target. It does not changeticket, which still reads 3; alias reads 9.
Copying a struct is not a recursive clone. If it contains a reference field, that reference is copied too, and the referenced object can remain shared. See value-type copy semantics.
Keep storage location separate
Storage depends on context; a type category alone does not promise that a variable lives on the stack. Here, AttemptStamp is also a field of WorkTicket. The language distinguishes local variables, fields and other variable categories. Captured locals can have extended lifetimes and may be represented in compiler-generated objects. Preserve the assignment reasoning even when storage changes. See the specification on variables and captured variables.
This program observes values and object identity. It does not measure memory addresses, allocation counts or copy performance.
Optional depth: boxing preserves a copy of the value
Boxing a non-nullable value type into object creates an object containing a copy. Unboxing back to the value type checks the boxed type and retrieves its value. It is different from a numeric conversion: unbox the actual type first, then convert the result. See boxing and unboxing.
BoxingCopy starts with six pending items. Compare the source integer, the saved boxed value, and a separately recovered integer after changing each ordinary integer variable.
using System;
int pending = 6;
object saved = pending;
pending = 11;
int recovered = (int)saved;
recovered++;
Console.WriteLine($"three values: pending={pending}; boxed={(int)saved}; recovered={recovered}");
object sameBox = saved;
Console.WriteLine($"same box: {ReferenceEquals(saved, sameBox)}");
long widened = (int)saved;
Console.WriteLine($"unbox int, then widen: {widened}");
try
{
long wrong = (long)saved;
Console.WriteLine($"unexpected long: {wrong}");
}
catch (InvalidCastException)
{
Console.WriteLine("direct long unbox: InvalidCastException");
}Expected output:
three values: pending=11; boxed=6; recovered=7 same box: True unbox int, then widen: 6 direct long unbox: InvalidCastException
The box retains 6 when pending changes to 11. Unboxing produces recovered=6, and incrementing recovered gives 7 without updating the box. Copying saved into sameBox copies the reference to that same box. The widening assignment succeeds after unboxing int; the direct long cast fails because saved contains a boxed int. The catch prints a stable label for that deliberate invalid-cast demonstration.
Nullable intent and runtime values
With nullable analysis enabled, string? communicates that a reference may be null. The compiler tracks null-state and warns about unsafe uses; the annotation does not create a different runtime reference type or insert input validation. See nullable reference types.
The original email display example is retained below, with extra calls for supplied and blank text. DisplayEmail expects a Customer object and handles that object's optional Email. It formats display text; it does not establish whether an email address exists or is valid.
using System;
static string DisplayEmail(Customer customer) =>
string.IsNullOrWhiteSpace(customer.Email)
? "(not supplied)"
: customer.Email.Trim();
var original = new Customer("Ada", null);
var renamed = original with { Name = "Ada Lovelace" };
Console.WriteLine(DisplayEmail(original));
Console.WriteLine(renamed.Name);
Console.WriteLine(DisplayEmail(new Customer("Ada", " ada@example.test ")));
Console.WriteLine(DisplayEmail(new Customer("Ada", " ")));
string? missing = null;
string promised = missing!;
Console.WriteLine($"after !, still null: {promised is null}");
public sealed record Customer(string Name, string? Email);Expected output:
(not supplied) Ada Lovelace ada@example.test (not supplied) after !, still null: True
The original two calls produce “(not supplied)” and “Ada Lovelace”. A supplied value loses its surrounding spaces; a blank value produces the missing-value label. The final experiment assigns an actual null through the null-forgiving operator and still prints True for the null test.
The postfix ! changes the compiler's null-state assumption, not the runtime value. Use an actual check when input can be missing; suppressing a warning does not repair the input. See the null-forgiving operator reference.
Optional applied practice: the delivery-address example
Build DeliveryAddress with a case-sensitive mode and an optional address argument. This exercise defines its policy explicitly: pickup needs no address and ignores one if supplied; delivery requires text that is nonempty after trimming. Nonblank text is the entire validation rule here. It does not prove postal correctness or deliverability, and printing a label does not arrange a shipment.
Validate argument count first, mode next, and the delivery address last. Expected missing or blank input returns false and an error message. A successful call produces a non-null trimmed address. The failure branches exit before PrintShippingLabel is called.
[NotNullWhen(true)] tells nullable analysis that the output is non-null when the method returns true. The implementation must honor that contract; the attribute supplies no runtime guard. After the false branch returns, the caller can pass address to the non-nullable parameter without using !. See conditional nullable postconditions.
using System;
using System.Diagnostics.CodeAnalysis;
static bool TryGetDeliveryAddress(string? input,
[NotNullWhen(true)] out string? address, out string error)
{
address = null;
if (input is null)
{
error = "Address is missing.";
return false;
}
string trimmed = input.Trim();
if (trimmed.Length == 0)
{
error = "Address is blank.";
return false;
}
address = trimmed;
error = "";
return true;
}
static void PrintShippingLabel(string address) =>
Console.WriteLine($"Deliver to: {address}");
if (args.Length < 1 || args.Length > 2)
{
Console.WriteLine("Usage: DeliveryAddress <pickup|delivery> [address]");
return 2;
}
string mode = args[0];
string? optionalAddress = args.Length == 2 ? args[1] : null;
if (mode == "pickup")
{
Console.WriteLine("Pickup: address not required.");
return 0;
}
if (mode != "delivery")
{
Console.WriteLine("Mode must be pickup or delivery.");
return 3;
}
if (!TryGetDeliveryAddress(optionalAddress, out string? address, out string error))
{
Console.WriteLine(error);
return 4;
}
PrintShippingLabel(address);
return 0;Run it and compare the results
For example, dotnet run -- pickup prints Pickup: address not required. and exits 0. For delivery, run this command:
dotnet run -- delivery " 8 Cedar Lane "
It prints Deliver to: 8 Cedar Lane and exits 0.
Expected case key: the brackets below describe the program's argument array, not text to paste as a shell command. An empty string is one supplied argument; it differs from an omitted address. A tab-only argument is represented as "\t".
[] → Usage: DeliveryAddress <pickup|delivery> [address]; exit 2. ["delivery", "8 Cedar Lane", "extra"] → Usage: DeliveryAddress <pickup|delivery> [address]; exit 2. ["ship"] → Mode must be pickup or delivery.; exit 3. ["Delivery", "8 Cedar Lane"] → Mode must be pickup or delivery.; exit 3. ["pickup"] → Pickup: address not required.; exit 0. ["pickup", ""] → Pickup: address not required.; exit 0. ["pickup", "unused"] → Pickup: address not required.; exit 0. ["delivery"] → Address is missing.; exit 4. ["delivery", ""] → Address is blank.; exit 4. ["delivery", " "] → Address is blank.; exit 4. ["delivery", "\t"] → Address is blank.; exit 4. ["delivery", "8 Cedar Lane"] → Deliver to: 8 Cedar Lane; exit 0. ["delivery", " 8 Cedar Lane "] → Deliver to: 8 Cedar Lane; exit 0.
Follow the delivery success path: optionalAddress may begin as null or supplied text; the helper rejects null, trims present text, rejects a zero-length result, assigns address and returns true. Only that successful path reaches PrintShippingLabel. The pickup path returns earlier under this exercise's different requirement.
Checks with answers
- What changes when copied.Count is incremented? Only the copied stamp's integer changes. The first stamp stays at 2.
- Why does changing alias.Attempts affect ticket? Before reassignment, both references reach the same WorkTicket. Changing a field in that object is different from assigning a new object reference to alias.
- Why does the box still contain 6? The source integer was copied at boxing. Later pending and recovered assignments affect their own integer values.
- Does missing! turn null into non-null text? It leaves the expression's existing value unchanged. In the shown string experiment, that value is null.
- What makes the delivery boundary safe for this contract? The null and trimmed-length checks implement the rule; the output annotation communicates its success condition to the compiler. Postal verification would need a separate requirement.
Failure modes and interview answer
- Predicting assignment behavior from an imagined stack location.
- Treating a copied reference as an independently cloned object, or confusing mutation with reassignment.
- Assuming the one-integer stamp demonstrates the cost of copying every struct.
- Expecting a direct long unbox to widen a boxed int.
- Using ! or an annotation in place of the actual delivery-address checks.
Interview answer: In the stamp assignment I copy its value. In the ticket assignment I copy a reference, so both variables initially reach the same object. Reassignment changes one variable's target. The boxing experiment creates a separate saved value, and the address example separates compiler-visible intent from runtime checks. Storage location is a separate question; I would inspect the concrete context rather than infer it from struct or class alone.
Analogy
Imagine two ways to share a maintenance record. You can photocopy a small paper card with a count on it, then write a new count on your own copy. Or you can hand someone another claim ticket for the same shared record board; changing that board is visible to both ticket holders. Replacing one claim ticket with a ticket for a different board leaves the other ticket unchanged.
Mapping. The paper card represents AttemptStamp, the shared board represents a WorkTicket object, and the claim ticket represents an object reference.
Where it stops. This example's card contains only an integer; a struct can also hold references. The picture explains assignment, mutation and reassignment, without promising a recursive copy, a stack or heap location, or the behavior of ref aliases.