Classes, Structs and Records
C# gives you three ways to declare a type, and the choice is about identity and copying rather than style.
- A class is a reference type. Two variables can name one object; equality is identity unless you say otherwise.
- A struct is a value type. Assigning copies it. Use one for a small, immutable value - a point, a money amount - and measure before anything bigger.
- A record is a class (or
record struct) with value equality, aToString, andwithgenerated for you.
var a = new PointClass(1, 2);
var b = new PointClass(1, 2);
Console.WriteLine($"class: equal={a.Equals(b)}"); // false: identity
var c = new PointStruct(1, 2);
var d = new PointStruct(1, 2);
Console.WriteLine($"struct: equal={c.Equals(d)}"); // true: value equality
var e = new PointRecord(1, 2);
var f = new PointRecord(1, 2);
Console.WriteLine($"record: equal={e == f}, {e}"); // true, and prints itself
var moved = e with { Y = 99 }; // a copy, one field changed
Console.WriteLine($"{e} -> {moved}");
class PointClass(int x, int y)
{
public int X { get; } = x;
public int Y { get; } = y;
}
readonly struct PointStruct(int x, int y)
{
public int X { get; } = x;
public int Y { get; } = y;
}
record PointRecord(int X, int Y);
Those (int x, int y) after the type names are primary constructors (C# 12):
the parameters are in scope for the whole body.
Properties are the public surface
A field is storage; a property is the interface to it. C# makes the property so cheap to write that a public field is almost never the right answer.
var t = new Temperature(21);
Console.WriteLine($"{t.Celsius}C is {t.Fahrenheit:F1}F");
t.Celsius = -10;
Console.WriteLine(t.Celsius);
try
{
t.Celsius = -300;
}
catch (ArgumentOutOfRangeException e)
{
Console.WriteLine($"rejected: {e.ParamName}");
}
Console.WriteLine(Temperature.AbsoluteZero.Celsius);
class Temperature
{
private double _celsius;
public Temperature(double celsius) => Celsius = celsius; // the setter validates
public static Temperature AbsoluteZero { get; } = new(-273.15);
public double Celsius
{
get => _celsius;
set
{
if (value < -273.15)
{
throw new ArgumentOutOfRangeException(nameof(value), "below absolute zero");
}
_celsius = value;
}
}
// A computed property: no storage, just a rule.
public double Fahrenheit => _celsius * 9 / 5 + 32;
}
init instead of set makes a property settable only during construction,
which is how you get an object that is immutable but still readable as an object
initialiser:
var course = new Course { Slug = "csharp", Title = "C#: From Basics to Expert" };
Console.WriteLine(course.Title);
// course.Title = "changed"; // will not compile: init-only
class Course
{
public required string Slug { get; init; } // required: the compiler insists
public required string Title { get; init; }
}
Equality, and the contract behind it
Override Equals and you must override GetHashCode, because every hash
collection uses the pair. A record does both for you, from its components -
which is the main reason to reach for one.
var x = new Money(500, "GBP");
var y = new Money(500, "GBP");
Console.WriteLine(x == y); // true
Console.WriteLine(new HashSet<Money> { x, y }.Count); // 1
Console.WriteLine(x + new Money(250, "GBP"));
try
{
_ = x + new Money(100, "USD");
}
catch (ArgumentException e)
{
Console.WriteLine(e.Message);
}
readonly record struct Money(long Pence, string Currency)
{
public static Money operator +(Money left, Money right)
=> left.Currency == right.Currency
? new Money(left.Pence + right.Pence, left.Currency)
: throw new ArgumentException($"cannot add {right.Currency} to {left.Currency}");
public override string ToString() => $"{Pence / 100.0:F2} {Currency}";
}
Inheritance, and the keyword Java does not have
Shape[] shapes = [new Rectangle(3, 4), new Circle(1)];
foreach (var shape in shapes) Console.WriteLine(shape.Describe());
abstract class Shape
{
public abstract double Area { get; }
// virtual: subclasses MAY replace it. Without this keyword they cannot -
// C# methods are sealed by default, unlike Java's.
public virtual string Describe() => $"{GetType().Name} of area {Area:F2}";
}
sealed class Rectangle(double width, double height) : Shape
{
public override double Area => width * height;
}
sealed class Circle(double radius) : Shape
{
public override double Area => Math.PI * radius * radius;
public override string Describe() => $"Circle r={radius}, {base.Describe()}";
}
override is compulsory, so you cannot accidentally hide a base method - and a
method you did not mark virtual cannot be overridden at all. That is the
opposite default from Java, and it is the safer one.
What to take away
- class for identity, struct for a small copied value, record for a value with data.
- Primary constructors (C# 12) put the parameters in scope for the whole type.
- Properties, not public fields;
initandrequiredgive immutability with initialisers. - Override
EqualsandGetHashCodetogether - or use a record and get both. - Methods are sealed unless marked
virtual, andoverrideis compulsory. withcopies a record and changes named members, leaving the original alone.