Classes, Structs and Records

Article · 14:00

Three ways to declare a type, and the choice is about identity and copying rather than style: a class is shared, a struct is copied, a record compares by value. With primary constructors, init and required, and the equality contract each one signs.

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, a ToString, and with generated 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; init and required give immutability with initialisers.
  • Override Equals and GetHashCode together - or use a record and get both.
  • Methods are sealed unless marked virtual, and override is compulsory.
  • with copies a record and changes named members, leaving the original alone.