C# & .NET (C# 14, .NET 10 LTS) Module 8 – Structs, records, tuples and value semantics
Structs, readonly structs and defensive copies
Declare small immutable C# structs with readonly struct and readonly members, predict every copy C# makes of them, and fix mutable-struct bugs.
What you will learn
- Declare a small immutable struct with readonly struct, checked init accessors and with expressions
- Predict where a call that changes a mutable struct lands, in a list, an array, a property or a foreach loop
- Find the defensive copies that readonly fields, in parameters and readonly members make
- Predict what new(), default and a new array give for a struct that has constructors
- Measure what a struct without equality members costs a HashSet, and remove the cost with IEquatable<T>
Before you start
What you’ll learn · Before you start
What you will learn
- Declare a small immutable struct with readonly struct, checked init accessors and with expressions
- Predict where a call that changes a mutable struct lands, in a list, an array, a property or a foreach loop
- Find the defensive copies that readonly fields, in parameters and readonly members make
- Predict what new(), default and a new array give for a struct that has constructors
- Measure what a struct without equality members costs a HashSet, and remove the cost with IEquatable<T>
Before you start
On this page
route[0].MoveBy(0.01, 0) compiles, runs and leaves the first stop of a route where it was, at latitude 18.9398.
The route is a List<GeoPoint>, GeoPoint is a struct, and the list handed MoveBy a copy. A struct is a value
type you declare yourself, like int or DateTime: a variable holds the whole value, C# copies all of it at every
assignment, argument and return, and most bugs with structs come from a copy that nobody noticed.
You will declare small structs that cannot change, predict every copy C# makes of one, including the hidden copies
behind readonly and in, and measure what a struct without equality members costs a HashSet.
A struct suits a small value that is copied whole
values.cs declares GeoPoint, a struct of two double properties for a latitude and a longitude, and prints how
many bytes one copy of it moves, beside some of .NET’s own structs:
// values.cs: how many bytes one copy of a value moves, for .NET's own structs and three of ours.
using System.Runtime.CompilerServices;
Size<int>("int");
Size<DateOnly>("DateOnly");
Size<DateTime>("DateTime");
Size<decimal>("decimal");
Size<Guid>("Guid");
Size<GeoPoint>("GeoPoint");
Size<(DateOnly, decimal)>("(DateOnly, decimal)");
Size<TrainStop>("TrainStop");
Size<string>("string, a reference");
// Assignment, an argument and a return value each copy every field of a struct.
var csmt = new GeoPoint(18.9398, 72.8355);
var copy = csmt;
Console.WriteLine($"csmt {csmt}, copy {copy}");
Console.WriteLine($"copy is equal to csmt: {copy.Equals(csmt)}");
Console.WriteLine($"Nudged(csmt) {Nudged(csmt)}, csmt is still {csmt}");
static GeoPoint Nudged(GeoPoint p) => new(p.Latitude + 0.5, p.Longitude);
static void Size<T>(string name) => Console.WriteLine($"{name,-22}{Unsafe.SizeOf<T>(),3} bytes");
struct GeoPoint
{
public GeoPoint(double latitude, double longitude)
{
Latitude = latitude;
Longitude = longitude;
}
public double Latitude { get; }
public double Longitude { get; }
public override string ToString() => $"({Latitude:F4}, {Longitude:F4})";
}
struct TrainStop
{
public string Station;
public GeoPoint Location;
public TimeOnly Arrival;
} Output
int 4 bytes DateOnly 4 bytes DateTime 8 bytes decimal 16 bytes Guid 16 bytes GeoPoint 16 bytes (DateOnly, decimal) 24 bytes TrainStop 32 bytes string, a reference 8 bytes csmt (18.9398, 72.8355), copy (18.9398, 72.8355) copy is equal to csmt: True Nudged(csmt) (19.4398, 72.8355), csmt is still (18.9398, 72.8355)
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run values.cs
copy = csmt copied both fields, so the two points are equal but separate, and Nudged(csmt) worked on a copy of its
own and returned a new point while csmt stayed where it was. Each of those copies moved all 16 bytes. Microsoft’s
Framework Design Guidelines turn this into a rule of thumb: consider a struct for a value that is small and
short-lived or embedded in other objects, and avoid one unless the type represents a single value, is under 16
bytes, cannot change and is rarely boxed. Here is the run against that rule:
| Type | Bytes per copy, measured | A good struct? |
|---|---|---|
int, DateOnly |
4 | yes |
DateTime |
8 | yes |
decimal, Guid, GeoPoint |
16 | at the limit, and each is one value that never changes |
(DateOnly, decimal) |
24 | 20 bytes of fields, padded to 24 |
TrainStop |
32 | no: a station, a point and a time are three values |
string |
8 | the reference only: the characters live in the string object |
The 24 bytes of (DateOnly, decimal) are the warning in that table: the runtime aligns each field, so the size of a
struct is measured, not added up. The guideline is an older rule of thumb that Microsoft republishes with a note that
parts of it may be out of date. A newer Microsoft article on avoiding copies draws the line at three words: copying
is cheap up to 24 bytes on a 64-bit runtime, and above that it can show up in measurements.
Checkpoint
Check: What a struct is for, and what a copy costs
A method called on a copy changes the copy
The GeoPoint of lost.cs can change: its fields are public, and MoveBy adds to them. The program calls
MoveBy(0.01, 0) on a point in five places and prints where each change landed:
// lost.cs: one mutable struct, MoveBy called in five places. Where does the change land?
var route = new List<GeoPoint> { new(18.9398, 72.8355), new(18.5286, 73.8743) };
route[0].MoveBy(0.01, 0);
Console.WriteLine($"1. List indexer route[0] = {route[0]}");
GeoPoint[] stops = [new(18.9398, 72.8355)];
stops[0].MoveBy(0.01, 0);
Console.WriteLine($"2. array element stops[0] = {stops[0]}");
var train = new Train { Position = new(18.9398, 72.8355) };
train.Position.MoveBy(0.01, 0);
Console.WriteLine($"3. property train.Position = {train.Position}");
foreach (var stop in route)
{
stop.MoveBy(0.01, 0);
}
Console.WriteLine($"4. foreach route[0] = {route[0]}");
var first = route[0];
first.MoveBy(0.01, 0);
route[0] = first;
Console.WriteLine($"5. copy and store route[0] = {route[0]}");
struct GeoPoint
{
public GeoPoint(double latitude, double longitude)
{
Latitude = latitude;
Longitude = longitude;
}
public double Latitude;
public double Longitude;
public void MoveBy(double dLatitude, double dLongitude)
{
Latitude += dLatitude;
Longitude += dLongitude;
}
public override string ToString() => $"({Latitude:F4}, {Longitude:F4})";
}
class Train
{
public GeoPoint Position { get; set; }
} Output
1. List indexer route[0] = (18.9398, 72.8355) 2. array element stops[0] = (18.9498, 72.8355) 3. property train.Position = (18.9398, 72.8355) 4. foreach route[0] = (18.9398, 72.8355) 5. copy and store route[0] = (18.9498, 72.8355)
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run lost.cs
Only the array element and the point that was copied out, changed and stored back reached 18.9498. An array element is
a variable of its own, so stops[0].MoveBy(...) changed the point inside the array. The indexer of a List<T> is a
method that returns a copy of the item, a property’s getter returns a copy of its value, and the variable of a
foreach loop is a read-only copy of the item, so in the three other places MoveBy changed a copy that vanished at
the end of the statement. Step through the list case and its fix:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- The point in the list. route is a List<GeoPoint>. Its first item is stored inside the list, at latitude 18.9398.
- route[0] reads a copy. route[0] calls the list's indexer, a method that returns the item. A struct is returned by value, so the statement now works on a hidden copy.
- MoveBy changes the copy. MoveBy adds 0.01 to the latitude of the copy, which becomes 18.9498. Nothing touches the item in the list.
- The copy is dropped. The statement ends and the copy goes with it. lost.cs printed route[0] = (18.9398, 72.8355): the change is lost, and no error or warning said so.
- Copy into a variable. The fix starts with var first = route[0]: a copy again, but now in a variable the code can keep.
- Change the variable. first.MoveBy(0.01, 0) changes the variable, which becomes 18.9498. The list still holds the old point.
- Store it back. route[0] = first calls the indexer's setter, which copies the changed point into the list. lost.cs printed route[0] = (18.9498, 72.8355).
The compiler stops the same mistake when it is written as an assignment to a field, and lets it through when it is a method call:
// Three assignments to a field of a struct that would land in a copy. C# refuses each one.
List<GeoPoint> route = [new GeoPoint { Latitude = 18.9398, Longitude = 72.8355 }];
route[0].Latitude = 18.9498;
var train = new Train();
train.Position.Latitude = 18.9498;
foreach (var stop in route)
{
stop.Latitude = 18.9498;
}
struct GeoPoint
{
public double Latitude;
public double Longitude;
}
class Train
{
public GeoPoint Position { get; set; }
} from session import run
# cs1612 assigns a field of a struct through a List indexer, a property and a foreach variable: three copies. The
# build is meant to fail.
run("""
cd cs1612
!dotnet build -v q
""") Output
$ cd cs1612
$ dotnet build -v q
cs1612/Program.cs(3,1): error CS1612: Cannot modify the return value of 'List<GeoPoint>.this[int]' because it is not a variable [cs1612/App.csproj]
cs1612/Program.cs(6,1): error CS1612: Cannot modify the return value of 'Train.Position' because it is not a variable [cs1612/App.csproj]
cs1612/Program.cs(10,5): error CS1654: Cannot modify members of 'stop' because it is a 'foreach iteration variable' [cs1612/App.csproj]
Build FAILED.
cs1612/Program.cs(3,1): error CS1612: Cannot modify the return value of 'List<GeoPoint>.this[int]' because it is not a variable [cs1612/App.csproj]
cs1612/Program.cs(6,1): error CS1612: Cannot modify the return value of 'Train.Position' because it is not a variable [cs1612/App.csproj]
cs1612/Program.cs(10,5): error CS1654: Cannot modify members of 'stop' because it is a 'foreach iteration variable' [cs1612/App.csproj]
0 Warning(s)
3 Error(s)
Time Elapsed …
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 cs1612_session.py
CS1612 refused route[0].Latitude = 18.9498 and train.Position.Latitude = 18.9498, because a field of a copy that is
about to vanish is not a variable, and CS1654 refused the assignment to the loop variable. A method is different: the
compiler does not look inside MoveBy to see whether it changes the struct, so all three calls compiled, and all
three lost their change without a word.
Changing a struct through a list, a property or foreach
route[0].MoveBy(0.01, 0); changes a copy. Copy the item into a variable, change it, then store it back:
var first = route[0]; first.MoveBy(0.01, 0); route[0] = first;. Better, make the struct readonly, so that no method
can change it in place and the code has to say where the new value goes.
Checkpoint
Check: Changes made on a copy
readonly struct turns every change into a new value
A readonly struct: A struct declared with readonly: every field must be readonly and every property get or init only, so no member can change an instance after it is built. A change makes a new value instead. promises that nothing changes it once it is built, and the compiler
holds it to that promise. readonlypoint.cs declares GeoPoint that way and gives MoveBy a new job, returning a
moved copy:
// readonlypoint.cs: a readonly struct. A change makes a new value, and every value is checked.
var csmt = new GeoPoint(18.9398, 72.8355);
var moved = csmt.MoveBy(0.01, 0);
Console.WriteLine($"csmt {csmt}, moved {moved}");
var route = new List<GeoPoint> { csmt };
route[0].MoveBy(0.01, 0);
Console.WriteLine($"result ignored route[0] = {route[0]}");
route[0] = route[0].MoveBy(0.01, 0);
Console.WriteLine($"result stored route[0] = {route[0]}");
var dadar = csmt with { Latitude = 19.0178, Longitude = 72.8478 };
Console.WriteLine($"with {{ ... }} {dadar}, csmt is still {csmt}");
try
{
_ = csmt with { Latitude = 91 };
}
catch (ArgumentOutOfRangeException e)
{
Console.WriteLine($"with {{ Latitude = 91 }} threw {e.GetType().Name} for {e.ParamName}");
}
try
{
_ = new GeoPoint(18.9398, 200);
}
catch (ArgumentOutOfRangeException e)
{
Console.WriteLine($"new GeoPoint(18.9398, 200) threw {e.GetType().Name} for {e.ParamName}");
}
readonly struct GeoPoint
{
public GeoPoint(double latitude, double longitude)
{
Latitude = latitude;
Longitude = longitude;
}
// C# 14: field is the property's own backing field, so the check runs in the constructor and in with.
public double Latitude
{
get;
init => field = value is >= -90 and <= 90 ? value : throw new ArgumentOutOfRangeException(nameof(Latitude));
}
public double Longitude
{
get;
init => field = value is >= -180 and <= 180 ? value : throw new ArgumentOutOfRangeException(nameof(Longitude));
}
public GeoPoint MoveBy(double dLatitude, double dLongitude) => new(Latitude + dLatitude, Longitude + dLongitude);
public override string ToString() => $"({Latitude:F4}, {Longitude:F4})";
} Output
csmt (18.9398, 72.8355), moved (18.9498, 72.8355)
result ignored route[0] = (18.9398, 72.8355)
result stored route[0] = (18.9498, 72.8355)
with { ... } (19.0178, 72.8478), csmt is still (18.9398, 72.8355)
with { Latitude = 91 } threw ArgumentOutOfRangeException for Latitude
new GeoPoint(18.9398, 200) threw ArgumentOutOfRangeException for Longitude
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run readonlypoint.cs
MoveBy returned (18.9498, 72.8355) and left csmt alone. On a list item, the bug of the last section is now in
plain sight: route[0].MoveBy(0.01, 0) makes a new point and drops it, while route[0] = route[0].MoveBy(0.01, 0)
stores it. A with expression, which works on structs since C# 10, copies a value and sets the properties it names,
as csmt with { Latitude = 19.0178, Longitude = 72.8478 } did for Dadar.
with sets properties through their init accessors and never calls a constructor, so a range check that lived only
in the constructor would let csmt with { Latitude = 91 } through. GeoPoint checks the range in its init
accessors, using the C# 14 field keyword for the property’s own storage, and its constructor assigns the
properties, so both paths ran the check and threw ArgumentOutOfRangeException. A readonly struct that tries to keep
something it can change does not build:
// A readonly struct with a field that can change and a property with set. C# refuses both.
Console.WriteLine("never runs");
readonly struct GeoPoint
{
public double Latitude;
public double Longitude { get; set; }
} from session import run
# readonly-errors declares a readonly struct with a field that is not readonly and a property with set. The build is
# meant to fail.
run("""
cd readonly-errors
!dotnet build -v q
""") Output
$ cd readonly-errors
$ dotnet build -v q
readonly-errors/Program.cs(6,19): error CS8340: Instance fields of readonly structs must be readonly. [readonly-errors/App.csproj]
readonly-errors/Program.cs(7,19): error CS8341: Auto-implemented instance properties in readonly structs must be readonly. [readonly-errors/App.csproj]
Build FAILED.
readonly-errors/Program.cs(6,19): error CS8340: Instance fields of readonly structs must be readonly. [readonly-errors/App.csproj]
readonly-errors/Program.cs(7,19): error CS8341: Auto-implemented instance properties in readonly structs must be readonly. [readonly-errors/App.csproj]
0 Warning(s)
2 Error(s)
Time Elapsed …
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 readonly_session.py
CS8340 requires every field of a readonly struct to be readonly, and CS8341 refuses an auto-property with set.
Microsoft’s reference adds the limit of the promise: a readonly struct that holds a List<T> cannot replace the
list, but anyone can still add to it.
Key idea
Make a small struct readonly and give it methods that return new values: then no copy can lose a change, because
nothing changes in place.
Checkpoint
Check: readonly struct and with
readonly fields and in parameters copy a mutable struct first
Odometer below is a mutable struct with an Add method. defensive calls Add through a readonly field of a
class, through an in parameter and from a readonly method, then keeps a readonly struct in an ordinary field:
// Program.cs: a mutable Odometer behind a readonly field, an in parameter and a readonly method.
var bus = new Bus();
bus.Drive(5);
bus.Drive(7);
Console.WriteLine($"1. readonly field {bus.Kilometres} km");
var odometer = new Odometer();
AddThrough(in odometer, 5);
Console.WriteLine($"2. in parameter {odometer.Kilometres} km");
odometer.AddTwiceReadonly(5);
Console.WriteLine($"3. readonly method {odometer.Kilometres} km");
var fixedBus = new FixedBus();
fixedBus.Drive(5);
fixedBus.Drive(7);
Console.WriteLine($"4. readonly struct {fixedBus.Kilometres} km");
static void AddThrough(in Odometer o, int km) => o.Add(km);
struct Odometer
{
public int Kilometres;
public void Add(int km) => Kilometres += km;
// A readonly member may call Add, but only on a copy of this struct.
public readonly void AddTwiceReadonly(int km)
{
Add(km);
Add(km);
}
}
class Bus
{
private readonly Odometer _odometer;
public void Drive(int km) => _odometer.Add(km);
public int Kilometres => _odometer.Kilometres;
}
readonly struct Reading
{
public Reading(int kilometres) => Kilometres = kilometres;
public int Kilometres { get; }
public Reading Plus(int km) => new(Kilometres + km);
}
class FixedBus
{
private Reading _reading;
public void Drive(int km) => _reading = _reading.Plus(km);
public int Kilometres => _reading.Kilometres;
} from session import run
# defensive calls a method that changes a mutable struct through a readonly field, an in parameter and a readonly
# method, then keeps a readonly struct in a field that is not readonly. It builds, with what the compiler warns
# about, and runs.
run("""
cd defensive
dotnet build -v q
dotnet bin/Debug/net10.0/App.dll
""") Output
$ cd defensive
$ dotnet build -v q
defensive/Program.cs(30,9): warning CS8656: Call to non-readonly member 'Odometer.Add(int)' from a 'readonly' member results in an implicit copy of 'this'. [defensive/App.csproj]
defensive/Program.cs(31,9): warning CS8656: Call to non-readonly member 'Odometer.Add(int)' from a 'readonly' member results in an implicit copy of 'this'. [defensive/App.csproj]
Build succeeded.
defensive/Program.cs(30,9): warning CS8656: Call to non-readonly member 'Odometer.Add(int)' from a 'readonly' member results in an implicit copy of 'this'. [defensive/App.csproj]
defensive/Program.cs(31,9): warning CS8656: Call to non-readonly member 'Odometer.Add(int)' from a 'readonly' member results in an implicit copy of 'this'. [defensive/App.csproj]
2 Warning(s)
0 Error(s)
Time Elapsed …
$ dotnet bin/Debug/net10.0/App.dll
1. readonly field 0 km
2. in parameter 0 km
3. readonly method 0 km
4. readonly struct 12 km
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 defensive_session.py
The first three lines report 0 km, although the calls added 12 km, 5 km and 10 km. A readonly field, an in
parameter and this inside a readonly member are read-only variables. Add carries no readonly mark, so for all
the compiler knows it writes to the struct, and the compiler keeps its promise by copying the struct into a hidden
variable and calling Add on that defensive copy: A hidden copy of a struct that the compiler makes before it calls a member that might change the struct on a read-only variable, such as a readonly field or an in parameter, so that the original cannot change., which is then thrown away. Step
through bus.Drive(5):
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- A readonly field. Bus keeps an Odometer, a mutable struct, in a readonly field. The field is a read-only variable: after the constructor, nothing may change it.
- Drive(5) calls Add. Add is not marked readonly, so it might change the struct. To keep the field unchanged, the compiler copies it into a hidden variable first.
- Add changes the copy. Add(5) adds 5 to the copy, which now reads 5 km. The field still reads 0 km.
- The copy is dropped. Drive returns and the copy goes. Drive(7) repeats all of it with a fresh copy of 0 km, so the program printed 0 km after 12 km of driving.
- A readonly struct instead. FixedBus keeps a Reading, a readonly struct, in an ordinary field. Reading has no method that changes it; Plus returns a new value.
- Drive(5) makes a new value. Plus(5) reads the ordinary field in place and returns a new Reading of 5 km. Reading has no member that changes it, so there is no change for a copy to lose.
- Drive stores it. Drive assigns the new value to the field. After Drive(7) does the same, the program printed 12 km.
The build warned only about the third case, with CS8656 at both calls inside the readonly method; the readonly field
and the in parameter compiled without a word. The fourth line is the fix: Reading is a readonly struct, Drive
stores the new value that Plus returns, and the field is an ordinary one, so the bus reached 12 km. When a struct
has to stay mutable, mark each member that does not change it as a
readonly member: A method, property or indexer of a struct marked readonly, which C# 8 added. It promises not to change the struct, so the compiler can call it on a read-only variable without a defensive copy., which C# 8 added, so that read-only variables can call it with no
copy.
Passing a mutable struct as in to save a copy
in passes a reference, but each call of a member that is not readonly on the parameter copies the whole struct
anyway, so a mutable struct passed as in can cost more than one passed by value. Use in for readonly structs.
Checkpoint
Check: Defensive copies of a mutable struct
new(), default and a new array do not always run your constructor
Since C# 10 a struct may declare a parameterless constructor and field initializers. Fare has both: its
parameterless constructor sets the paise, and an initializer sets the currency to INR. defaults.cs asks for a
Fare in five ways:
// defaults.cs: what new(), default and a new array give for a struct with a parameterless constructor.
Console.WriteLine($"new Fare() {new Fare()}");
Console.WriteLine($"new Fare(25_000) {new Fare(25_000)}");
Console.WriteLine($"default(Fare) {default(Fare)}");
Fare[] fares = new Fare[2];
Console.WriteLine($"new Fare[2], item 0 {fares[0]}");
Console.WriteLine($"Make<Fare>() {Make<Fare>()}");
// C# 11 auto-default structs: a constructor may leave a field unassigned; it is then 0, false or null.
var seat = new Seat(12);
Console.WriteLine($"new Seat(12) number {seat.Number}, coach {seat.Coach ?? "null"}");
static T Make<T>() where T : new() => new T();
readonly struct Fare
{
public Fare()
{
Paise = 0;
}
public Fare(long paise)
{
Paise = paise;
}
public long Paise { get; }
// A field initializer: every constructor runs it, and default skips it.
public string Currency { get; } = "INR";
public override string ToString() => $"{Paise} paise, currency {Currency ?? "null"}";
}
struct Seat
{
public Seat(int number)
{
Number = number;
}
public int Number;
public string? Coach;
} Output
new Fare() 0 paise, currency INR new Fare(25_000) 25000 paise, currency INR default(Fare) 0 paise, currency null new Fare[2], item 0 0 paise, currency null Make<Fare>() 0 paise, currency INR new Seat(12) number 12, coach null
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run defaults.cs
| Expression | Runs a constructor? | What it gave |
|---|---|---|
new Fare() |
yes, the parameterless one, with the initializer | 0 paise, INR |
new Fare(25_000) |
yes, with the initializer | 25000 paise, INR |
new T() in generic code |
yes, the parameterless one | 0 paise, INR |
default(Fare) |
no | 0 paise, currency null |
new Fare[2] |
no, for any element | 0 paise, currency null |
default and array creation skip every constructor and give the all-zero value: 0 for numbers, false, and null
for references. Every struct has that value, and nothing can stop code from making it. That is why Microsoft’s struct
design guideline asks that the all-zero state be valid. The same guideline still says that C# does not allow
parameterless struct constructors, which C# 10 changed; its reason holds all the same, because arrays never run one.
new Seat(12) shows the C# 11 rule of auto-default structs: a field that a constructor leaves unassigned starts at its
default, null for the coach here, where older compilers stopped with an error.
Checkpoint
Check: new(), default and arrays
A struct without equality members boxes, and may hash alike
PlainSeat holds a coach and a seat number and declares nothing else. EquatableSeat adds the equality members of
the equality lesson: IEquatable<T>, GetHashCode built with HashCode.Combine, == and !=. A
readonly record struct gets the same members from the compiler, as the next lesson shows. boxing.cs counts the heap
bytes of 1,000 calls of each, and the hash codes of the 72 seats of coach B2:
// boxing.cs: heap bytes for 1,000 comparisons, lookups and casts of a seat, and the hash codes of 72 seats.
using System.Runtime.CompilerServices;
var plain = new PlainSeat("B2", 12);
var equatable = new EquatableSeat("B2", 12);
var record = new RecordSeat("B2", 12);
Console.WriteLine("Heap bytes for 1,000 calls a.Equals(b) set.Contains(a) object o = a");
Console.WriteLine($"PlainSeat, no equality members {PlainEquals(plain, plain),11} {ContainsBytes(plain),15} {BoxBytes(plain),12}");
Console.WriteLine($"EquatableSeat, IEquatable<T> {EquatableEquals(equatable, equatable),11} {ContainsBytes(equatable),15} {BoxBytes(equatable),12}");
Console.WriteLine($"readonly record struct {RecordEquals(record, record),11} {ContainsBytes(record),15} {BoxBytes(record),12}");
Console.WriteLine();
Console.WriteLine("Different hash codes among seats 1 to 72 of coach B2:");
Console.WriteLine($"PlainSeat {Enumerable.Range(1, 72).Select(n => new PlainSeat("B2", n).GetHashCode()).Distinct().Count()}");
Console.WriteLine($"EquatableSeat {Enumerable.Range(1, 72).Select(n => new EquatableSeat("B2", n).GetHashCode()).Distinct().Count()}");
Console.WriteLine($"RecordSeat {Enumerable.Range(1, 72).Select(n => new RecordSeat("B2", n).GetHashCode()).Distinct().Count()}");
// Each measurement runs once to warm up, then 1,000 times between two readings of the thread's allocation counter.
[MethodImpl(MethodImplOptions.AggressiveOptimization)]
static long PlainEquals(PlainSeat a, PlainSeat b)
{
bool same = a.Equals(b);
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 1000; i++) same &= a.Equals(b);
return same ? GC.GetAllocatedBytesForCurrentThread() - before : -1;
}
[MethodImpl(MethodImplOptions.AggressiveOptimization)]
static long EquatableEquals(EquatableSeat a, EquatableSeat b)
{
bool same = a.Equals(b);
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 1000; i++) same &= a.Equals(b);
return same ? GC.GetAllocatedBytesForCurrentThread() - before : -1;
}
[MethodImpl(MethodImplOptions.AggressiveOptimization)]
static long RecordEquals(RecordSeat a, RecordSeat b)
{
bool same = a.Equals(b);
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 1000; i++) same &= a.Equals(b);
return same ? GC.GetAllocatedBytesForCurrentThread() - before : -1;
}
[MethodImpl(MethodImplOptions.AggressiveOptimization)]
static long ContainsBytes<T>(T seat) where T : struct
{
var set = new HashSet<T> { seat };
bool found = set.Contains(seat);
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 1000; i++) found &= set.Contains(seat);
return found ? GC.GetAllocatedBytesForCurrentThread() - before : -1;
}
[MethodImpl(MethodImplOptions.AggressiveOptimization)]
static long BoxBytes<T>(T seat) where T : struct
{
var boxes = new object[1000];
boxes[0] = seat;
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < boxes.Length; i++) boxes[i] = seat;
return GC.GetAllocatedBytesForCurrentThread() - before;
}
struct PlainSeat(string coach, int number)
{
public string Coach { get; } = coach;
public int Number { get; } = number;
}
readonly struct EquatableSeat(string coach, int number) : IEquatable<EquatableSeat>
{
public string Coach { get; } = coach;
public int Number { get; } = number;
public bool Equals(EquatableSeat other) => Coach == other.Coach && Number == other.Number;
public override bool Equals(object? obj) => obj is EquatableSeat other && Equals(other);
public override int GetHashCode() => HashCode.Combine(Coach, Number);
public static bool operator ==(EquatableSeat left, EquatableSeat right) => left.Equals(right);
public static bool operator !=(EquatableSeat left, EquatableSeat right) => !left.Equals(right);
}
readonly record struct RecordSeat(string Coach, int Number); Output
Heap bytes for 1,000 calls a.Equals(b) set.Contains(a) object o = a PlainSeat, no equality members 152000 184000 32000 EquatableSeat, IEquatable<T> 0 0 32000 readonly record struct 0 0 32000 Different hash codes among seats 1 to 72 of coach B2: PlainSeat 1 EquatableSeat 72 RecordSeat 72
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run boxing.cs
PlainSeat allocated 152 bytes for every Equals call and 184 for every Contains, while the other two allocated
nothing. Its Equals is ValueType.Equals(object), so the argument is boxing: Storing a value of a value type, such as an int, in a variable of type object or of an interface type. Getting the value back out is unboxing, and it must name exactly the type that went in. More in “Conversions, casting and parsing” first, and
Microsoft’s reference advises overriding it, above all for a struct with reference fields. Casting any of the three to
object cost a box of 32 bytes each time.
The last three lines are worse news. The default GetHashCode gave the 72 seats of coach B2 a single
hash code: The int that GetHashCode returns, which a HashSet or a Dictionary uses to find the place where a value belongs. Equal values must have equal hash codes, while different values may share one. More in “Equality, hashing, operators and conversions”: it read the coach and ignored the number, as its reference warns it may. In
a HashSet<PlainSeat> every seat of a coach lands in one place, and each lookup compares against all of them.
Key idea
A struct used in comparisons, sets or dictionary keys needs equality of its own: implement IEquatable<T> and
GetHashCode from the same fields, or declare a readonly record struct.
Checkpoint
Check: Equality, boxing and hash codes of a struct
Practise: a GeoPoint that stays valid
geopoint.cs checks a GeoPoint that you complete: a readonly struct that refuses a latitude outside -90 to 90 and a
longitude outside -180 to 180, compares without boxing, works out the distance between two points and reads text
such as 18.9398,72.8355. The coordinates are approximate. The struct you start from only throws, so all 8 checks
fail:
// geopoint.cs: eight checks of GeoPoint, a readonly struct you complete. Coordinates are approximate.
using System.Globalization;
using System.Runtime.CompilerServices;
var passed = 0;
Check("is a readonly struct", () => typeof(GeoPoint).GetCustomAttributes(false).Any(a => a.GetType().Name == "IsReadOnlyAttribute"));
Check("keeps latitude and longitude", () => Csmt().Latitude == 18.9398 && Csmt().Longitude == 72.8355);
Check("refuses latitude 91 and longitude -181", () =>
Throws<ArgumentOutOfRangeException>(() => new GeoPoint(91, 0)) && Throws<ArgumentOutOfRangeException>(() => new GeoPoint(0, -181)));
Check("refuses latitude 95 in a with expression", () => Throws<ArgumentOutOfRangeException>(() => Csmt() with { Latitude = 95 }));
Check("equal points: ==, Equals and one entry in a set", () =>
Csmt() == new GeoPoint(18.9398, 72.8355) && Csmt().Equals(new GeoPoint(18.9398, 72.8355)) && Csmt() != Pune()
&& new HashSet<GeoPoint> { Csmt(), new(18.9398, 72.8355), Pune() }.Count == 2);
Check("Equals allocates nothing", () => EqualsBytes(Csmt(), Pune()) == 0);
Check("CSMT to Pune Junction is about 118.6 km", () => Math.Abs(Csmt().DistanceKm(Pune()) - 118.6) < 0.1);
Check("Parse reads \"28.6415,77.2190\" and refuses \"north\"", () =>
GeoPoint.Parse("28.6415,77.2190") == new GeoPoint(28.6415, 77.2190) && Throws<FormatException>(() => GeoPoint.Parse("north")));
Console.WriteLine($"{passed} of 8 checks pass");
return passed == 8 ? 0 : 1;
static GeoPoint Csmt() => new(18.9398, 72.8355);
static GeoPoint Pune() => new(18.5286, 73.8743);
void Check(string name, Func<bool> test)
{
bool ok;
try { ok = test(); }
catch (Exception e) { Console.WriteLine($"FAIL {name}: {e.GetType().Name}"); return; }
Console.WriteLine($"{(ok ? "pass" : "FAIL")} {name}");
if (ok) passed++;
}
static bool Throws<TException>(Func<object> action) where TException : Exception
{
try { action(); return false; }
catch (TException) { return true; }
}
[MethodImpl(MethodImplOptions.AggressiveOptimization)]
static long EqualsBytes(GeoPoint a, GeoPoint b)
{
a.Equals(b);
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 1000; i++) a.Equals(b);
return GC.GetAllocatedBytesForCurrentThread() - before;
}
// ---- GeoPoint: complete it ---------------------------------------------------------------------------------------
struct GeoPoint : IEquatable<GeoPoint>
{
public GeoPoint(double latitude, double longitude) => throw new NotImplementedException();
public double Latitude { get; init; }
public double Longitude { get; init; }
// The great-circle distance by the haversine formula, on a sphere of radius 6,371 km.
public double DistanceKm(GeoPoint other) => throw new NotImplementedException();
// "18.9398,72.8355": latitude, a comma, longitude, read with the invariant culture.
public static GeoPoint Parse(string text) => throw new NotImplementedException();
public bool Equals(GeoPoint other) => throw new NotImplementedException();
public override bool Equals(object? obj) => obj is GeoPoint other && Equals(other);
public override int GetHashCode() => throw new NotImplementedException();
public static bool operator ==(GeoPoint left, GeoPoint right) => left.Equals(right);
public static bool operator !=(GeoPoint left, GeoPoint right) => !left.Equals(right);
public override string ToString() => $"({Latitude:F4}, {Longitude:F4})";
} Output (exit status 1)
FAIL is a readonly struct FAIL keeps latitude and longitude: NotImplementedException FAIL refuses latitude 91 and longitude -181: NotImplementedException FAIL refuses latitude 95 in a with expression: NotImplementedException FAIL equal points: ==, Equals and one entry in a set: NotImplementedException FAIL Equals allocates nothing: NotImplementedException FAIL CSMT to Pune Junction is about 118.6 km: NotImplementedException FAIL Parse reads "28.6415,77.2190" and refuses "north": NotImplementedException 0 of 8 checks pass
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run geopoint.cs
Complete GeoPoint
Copy geopoint.cs to your computer. Make the struct readonly, check each range in the init accessor with the
field keyword and assign both properties in the constructor, so that with is checked too. Compare both
properties in Equals(GeoPoint) and hash them with HashCode.Combine. For DistanceKm, use the haversine
formula on a sphere of radius 6,371 km, with double.DegreesToRadians. In Parse, split at the comma and read each
part with CultureInfo.InvariantCulture. Run dotnet run geopoint.cs until it prints 8 of 8 checks pass.
Then check what you now predict about copies, readonly structs, defaults and equality. GeoPoint, Fare and the
seats of boxing.cs come back beside new cases, starting from your weakest idea, with a reason under every choice:
Practice that adapts to you
Practise this lesson
18 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · What does readonly struct guarantee, and why does it help?
A readonly struct guarantees that no member can change an instance after it is built: the compiler requires every
field to be readonly and every property to be get or init only, so a change means building a new value, as
with or a method like MoveBy that returns a moved copy does. That helps twice. A copy can never lose a change,
because nothing changes in place, so the bugs of struct copies in lists, properties and loops disappear. And read-only
variables, such as readonly fields and in parameters, can call its members with no defensive copy. The guarantee
is shallow: a reference field still points to an object that can change, such as a list that callers can add to.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
A game keeps its enemies in a List<Enemy>, where Enemy is a mutable struct with a TakeDamage method. Players report that enemies never die: units[i].TakeDamage(25) runs on every hit, yet Health stays at 100. Explain what happens, and how you would fix the code and the design.
-
Mid to senior
A booking service keys a Dictionary by a struct SeatKey(string Coach, int Number) that declares no equality members. A profiler shows about 150 bytes allocated per lookup, and lookups get slower as a train fills up. Explain both symptoms, and how you would fix them and prove the fix.
Key takeaways
- A struct is copied whole at every assignment, argument and return, so it suits a small value of one meaning that never changes, and its size is measured with padding, not added up.
- A method that changes a struct works on a copy when it is called through a list indexer, a property or a
foreachvariable, and the compiler refuses the field assignment but not the method call. - A
readonly structmakes every change a new value;withcopies one and runsinitaccessors, so checks belong in them. - A readonly field, an
inparameter and areadonlymember copy a mutable struct before calling a member that is notreadonly, and only the last case gets a warning. defaultand new arrays skip every constructor, so the all-zero value must be valid, and a struct used in sets needsIEquatable<T>andGetHashCode, or the record struct’s generated members.
Terms in this lesson
- defensive copy
- A hidden copy of a struct that the compiler makes before it calls a member that might change the struct on a read-only variable, such as a readonly field or an in parameter, so that the original cannot change.
- readonly member
- A method, property or indexer of a struct marked readonly, which C# 8 added. It promises not to change the struct, so the compiler can call it on a read-only variable without a defensive copy.
- readonly struct
- A struct declared with readonly: every field must be readonly and every property get or init only, so no member can change an instance after it is built. A change makes a new value instead.
References
- Structure types (C# reference) (Microsoft Learn)
- Choosing between class and struct (Framework Design Guidelines) (Microsoft Learn)
- Struct design (Framework Design Guidelines) (Microsoft Learn)
- Avoid memory allocations and data copies (Microsoft Learn)
- Compiler Error CS1612 (Microsoft Learn)
- The with expression (Microsoft Learn)
- The field keyword (Microsoft Learn)
- The history of C# (versions of readonly struct, readonly members and struct initialization) (Microsoft Learn)
- ValueType.Equals(Object) method (Microsoft Learn)
- ValueType.GetHashCode method (Microsoft Learn)
- Unsafe.SizeOf method (Microsoft Learn)
- GC.GetAllocatedBytesForCurrentThread method (Microsoft Learn)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress