Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 15 minutes
  • Examples run with .NET 10.0.401 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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: the bytes in one copy of a value C# · values/values.cs
// 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

  1. Question 1 of 2 A DateOnly takes 4 bytes and a decimal 16. How many bytes did values.cs measure for one (DateOnly, decimal)?

    Type a number.

    A hint

    The fields add up to 20, but the runtime aligns each field.

    Show the answer: Question 1 of 2

    Answer: 24 bytes

    Unsafe.SizeOf reported 24 bytes: 4 for the DateOnly, 16 for the decimal and 4 of padding, because the decimal is aligned to 8 bytes. A struct's size is measured, not added up.

  2. Question 2 of 2 TrainStop holds a station name, a GeoPoint and an arrival time, 32 bytes in all. What does the guideline suggest for it?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A class or a record: it is three values, over 16 bytes

    TrainStop is a group of three values that a program would pass around often, so copying 32 bytes each time buys nothing. A class or a record fits it; GeoPoint, one 16-byte value, stays a struct.

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: MoveBy called in five places C# · lost/lost.cs
// 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:

Interactive · step by step Where route[0].MoveBy(0.01, 0) lands Step through the call on the list item, then the fix. The list's storage is on the left; the copies the code works on are on the right.
Where route[0].MoveBy(0.01, 0) lands, step 7 of 7. Showing route[0], in the list ((18.9498, 72.8355)), first, a variable ((18.9498, 72.8355)). In focus: route[0], in the list.first = route[0]route[0] = firstroute[0], in the list(18.9498, 72.8355)first, a variable(18.9498, 72.8355)a copy

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. The point in the list. route is a List<GeoPoint>. Its first item is stored inside the list, at latitude 18.9398.
  2. 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.
  3. 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.
  4. 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.
  5. Copy into a variable. The fix starts with var first = route[0]: a copy again, but now in a variable the code can keep.
  6. Change the variable. first.MoveBy(0.01, 0) changes the variable, which becomes 18.9498. The list still holds the old point.
  7. 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:

cs1612/Program.cs C# · cs1612/Program.cs
// 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; }
}
Building cs1612 Python · cs1612_session.py
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

  1. Question 1 of 2 GeoPoint is a mutable struct with a MoveBy method. Which calls change the point that is stored?

    Choose every answer that is right.

    Show the answer: Question 1 of 2

    Answer:

    • stops[0].MoveBy(0.01, 0) on a GeoPoint[]

    Only a variable that holds the struct itself, such as an array element, a local or a field, can be changed in place. An indexer, a getter and a foreach variable all hand out copies.

  2. Question 2 of 2 route[0].Latitude = 18.9498 stops the build with CS1612. Why does route[0].MoveBy(0.01, 0) compile?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The compiler does not check whether a method changes the struct, so it allows the call on a copy

    The compiler stops the obvious case, an assignment to a field of a copy, and cannot stop the hidden one, a method that changes the copy. A readonly struct removes the hidden case.

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 GeoPoint that cannot change C# · readonlypoint/readonlypoint.cs
// 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:

readonly-errors/Program.cs C# · readonly-errors/Program.cs
// 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; }
}
Building readonly-errors Python · readonly_session.py
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

  1. Question 1 of 2 Which member may a readonly struct declare?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: public double Latitude { get; init; }

    A readonly struct allows readonly fields and get or init properties only, so every instance keeps the values it was built with.

  2. Question 2 of 2 GeoPoint checks its latitude only in its constructor, and its properties are { get; init; }. What does csmt with { Latitude = 91 } do?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It makes a GeoPoint with latitude 91: with sets the init accessor and skips the constructor

    Put the check where every path passes: in the init accessor, with the field keyword, and let the constructor assign the properties so that it runs the same check.

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:

defensive/Program.cs C# · defensive/Program.cs
// 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;
}
Building and running defensive Python · defensive_session.py
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):

Interactive · step by step What bus.Drive(5) does to a readonly field Step through two calls of Drive on a Bus whose Odometer field is readonly, then the same drive with a readonly struct.
What bus.Drive(5) does to a readonly field, step 7 of 7. Showing _odometer, _reading (ordinary field, 12 km), The value Plus returns (5 km). In focus: _reading.Plus reads itDrive stores it_odometerreadonly field, 0 km_readingordinary field, 12 kmThe value Plus returns5 kmstores

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. Add changes the copy. Add(5) adds 5 to the copy, which now reads 5 km. The field still reads 0 km.
  4. 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.
  5. 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.
  6. 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.
  7. 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

  1. Question 1 of 2 A class keeps a mutable Odometer struct in a readonly field and calls _odometer.Add(5), then _odometer.Add(7). How many kilometres does the field hold afterwards?

    Type a number.

    A hint

    What does the compiler do before it calls a member that is not readonly on a readonly field?

    Show the answer: Question 1 of 2

    Answer: 0 km

    Each call worked on a defensive copy of the field, which was thrown away, so the field still holds 0 km, as the first line of defensive printed.

  2. Question 2 of 2 In the defensive session, which of the three hidden copies did the compiler warn about?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Only the calls inside the readonly method, with CS8656

    Because two of the three cases are silent, the safe habit is to declare small structs readonly, or at least to mark every member that does not change the struct readonly.

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: five ways to get a Fare C# · defaults/defaults.cs
// 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

  1. Question 1 of 2 Fare has a parameterless constructor and the initializer Currency = "INR". What does default(Fare) give?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 0 paise and a null currency

    new Fare() and default(Fare) differ as soon as a struct has a parameterless constructor or field initializers.

  2. Question 2 of 2 new Fare[1000] creates 1,000 Fare values. How many times does Fare's parameterless constructor run?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 0 times

    Creating an array of structs fills it with the all-zero value and runs no constructor, which is why the element that defaults.cs printed had a null currency, and why the all-zero value must be a valid one.

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: equality, lookups and hash codes, measured C# · boxing/boxing.cs
// 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

  1. Question 1 of 2 How many heap bytes did boxing.cs measure for one call of PlainSeat's Equals, which it inherits from ValueType?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 152 bytes

    1,000 calls allocated 152,000 bytes, 152 a call: the argument is boxed and the fields are compared one by one through reflection. EquatableSeat and the record struct allocated nothing.

  2. Question 2 of 2 A HashSet<PlainSeat> holds the 72 seats of coach B2 and gets slow. Why?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The default GetHashCode gave all 72 seats one hash code, so every lookup compares them one by one

    A hash-based collection is only as good as its hash codes. Build GetHashCode from the same fields as Equals, with HashCode.Combine, or use a record 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 C# · practice/geopoint.cs
// 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.

GPS Coordinate Converter (Lat/Long, DMS, UTM, MGRS) Convert a station's coordinates from degrees, minutes and seconds to the decimal degrees that GeoPoint takes.

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.

  1. Question 1 of 18 Which set of properties does the Framework Design Guidelines' rule of thumb ask of a struct?

    Choose one answer.

    Show the answer: Question 1 of 18

    Answer: One value, under 16 bytes, immutable, rarely boxed

    A struct should behave like int or DateTime: one small value that never changes and is rarely cast to object.

  2. Question 2 of 18 Nudged(csmt) receives a GeoPoint. What does the call copy?

    Choose one answer.

    Show the answer: Question 2 of 18

    Answer: All 16 bytes of the point, into the parameter

    Every assignment, argument and return value copies the whole struct, which is why size matters.

  3. Question 3 of 18 A team adds a byte flag to a 16-byte readonly struct that holds a decimal. What should they expect, and do?

    Choose one answer.

    Show the answer: Question 3 of 18

    Answer: The size can grow to 24 bytes with padding: measure it with Unsafe.SizeOf before deciding

    The guideline's 16 bytes is about copy cost, and the true size includes padding, so measure after every change of fields.

  4. Question 4 of 18 route is a List<GeoPoint> of mutable points. Which code moves the first point?

    Choose one answer.

    Show the answer: Question 4 of 18

    Answer: var first = route[0]; first.MoveBy(0.01, 0); route[0] = first;

    Copy, change, store back, or use a readonly struct and store the new value its method returns.

  5. Question 5 of 18 Train has public GeoPoint Position { get; set; } and GeoPoint is mutable. What happens to train.Position after train.Position.MoveBy(0.01, 0)?

    Choose one answer.

    Show the answer: Question 5 of 18

    Answer: It is unchanged: the getter returned a copy, and the copy moved

    A getter of a struct type always hands out a copy, so a change has to be written back with the setter.

  6. Question 6 of 18 A game keeps List<Unit>, with Unit a mutable struct, and calls units[i].TakeDamage(10) each hit. Health never drops. What is the best fix?

    Choose one answer.

    Show the answer: Question 6 of 18

    Answer: Make Unit a class, since a unit has an identity that changes over time, or store the value back after each change

    Mutable data with an identity is what classes are for. If the struct must stay, every change has to be stored back into the list.

  7. Question 7 of 18 What does readonly struct guarantee?

    Choose one answer.

    Show the answer: Question 7 of 18

    Answer: No member changes an instance after it is built

    readonly struct makes the struct's own fields fixed for its whole life; what its references point to is another matter.

  8. Question 8 of 18 GeoPoint is now a readonly struct whose MoveBy returns a moved copy. What does route[0].MoveBy(0.01, 0); do on a List<GeoPoint>?

    Choose one answer.

    Show the answer: Question 8 of 18

    Answer: It makes a moved point and drops it; route[0] keeps its value

    With a readonly struct the lost change can no longer hide: the code either stores the new value or visibly throws it away.

  9. Question 9 of 18 A Pin struct (6 digits) must never hold "abc", whether built by new Pin("abc") or by pin with { Code = "abc" }. Where does the check go?

    Choose one answer.

    Show the answer: Question 9 of 18

    Answer: In the init accessor of Code, with the field keyword, and the constructor assigns Code

    The init accessor is the one door that both construction and with go through, which is why GeoPoint checks there.

  10. Question 10 of 18 Calling a member that is not readonly on a mutable struct makes a defensive copy when the struct is in which places?

    Choose every answer that is right.

    Show the answer: Question 10 of 18

    Answer:

    • A readonly field
    • this inside a readonly member
    • An in parameter

    Read-only variables make defensive copies of mutable structs; ordinary locals, fields and array elements do not.

  11. Question 11 of 18 A mutable struct has a Total property that only reads its fields, and it is passed as in. How do you stop the copy at each Total read?

    Choose one answer.

    Show the answer: Question 11 of 18

    Answer: Mark the getter readonly: public readonly double Total => …

    readonly members are the tool for structs that must stay mutable; for small values, a readonly struct removes the question entirely.

  12. Question 12 of 18 A team passes a 64-byte mutable struct as in to save copies, and its methods call three members that are not readonly. What happens?

    Choose one answer.

    Show the answer: Question 12 of 18

    Answer: Each of the three calls copies all 64 bytes, so the method can copy more than a plain parameter would

    in pays only with readonly structs or readonly members. With a mutable struct it hides copies the code never shows.

  13. Question 13 of 18 Which of these runs a struct's parameterless constructor?

    Choose one answer.

    Show the answer: Question 13 of 18

    Answer: new Fare()

    Only new runs a struct's parameterless constructor; every other way of getting a value gives the all-zero one.

  14. Question 14 of 18 Seat(int number) assigns Number but not Coach. What does new Seat(12) hold on C# 11 or later?

    Choose one answer.

    Show the answer: Question 14 of 18

    Answer: Number 12 and Coach null

    C# 11 removed the need to assign every field in a struct constructor, but the rule it relies on is the same: the default of each field is zero, false or null.

  15. Question 15 of 18 A Currency struct holds a three-letter code, and default(Currency) has a null code that crashes formatting. Which design follows the guideline?

    Choose one answer.

    Show the answer: Question 15 of 18

    Answer: Make the all-zero value mean something valid, for example treat a null code as INR in the getter

    Every struct has a default value that skips its constructors, so either make zero valid or choose a class.

  16. Question 16 of 18 boxing.cs stored a 16-byte seat in an object variable 1,000 times. How many heap bytes did that allocate?

    Type a number.

    Show the answer: Question 16 of 18

    Answer: 32000 bytes

    Each cast to object made a new box of 32 bytes, for all three kinds of seat: boxing allocates whatever equality members a struct has.

  17. Question 17 of 18 What removed the allocations of Equals and Contains for EquatableSeat?

    Choose one answer.

    Show the answer: Question 17 of 18

    Answer: IEquatable<EquatableSeat>.Equals takes the struct itself, so nothing is boxed, and GetHashCode uses both fields

    The fix is the four members that agree, from the equality lesson, or a record struct, which generates them.

  18. Question 18 of 18 A struct (string Station, int Platform) is the key of a dictionary of 10 lakh entries, and lookups are slow. What do you check first?

    Choose one answer.

    Show the answer: Question 18 of 18

    Answer: Whether it implements IEquatable<T> and a GetHashCode over both fields; the default may hash every platform of a station alike

    Default struct equality boxes, and its hash code may read only the first field. A key type needs its own equality.

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.

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

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

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

    Model answer, Pro. First step and follow-ups

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 foreach variable, and the compiler refuses the field assignment but not the method call.
  • A readonly struct makes every change a new value; with copies one and runs init accessors, so checks belong in them.
  • A readonly field, an in parameter and a readonly member copy a mutable struct before calling a member that is not readonly, and only the last case gets a warning.
  • default and new arrays skip every constructor, so the all-zero value must be valid, and a struct used in sets needs IEquatable<T> and GetHashCode, 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.