Free Handbook · Every example compiled & verified

Classes & Objects

Model data with C# classes: properties, constructors and primary constructors, static members, records, structs, equality and nullable reference types.

0 / 142 lessons🔥 0 day streak
ShareXLinkedIn

Module 05 · what you'll be able to do

  • Write a class with private fields, validated properties, constructors and methods, and create objects from it
  • Use init-only and required properties, primary constructors and object initializers to build objects safely
  • Choose between a class, a record and a struct from how each one copies and compares
  • Override ToString, Equals and GetHashCode correctly, and know when a record does it for you
  • Read nullable reference type warnings and prevent a NullReferenceException before it happens
01

Classes, objects and this

A class is a blueprint: it declares what data an object holds (its fields) and what it can do (its methods). An object (or instance) is one thing built from that blueprint with new. Two objects of the same class have their own copies of every field, so changing one account's balance leaves the other alone.

Inside an instance method, this means "the object this method was called on". You mostly leave it out, since balance already means this.balance, but you need it when a parameter has the same name as a field, or when you pass the current object to another method.

C#Program.cs
var ana = new BankAccount("Ana");
var ben = new BankAccount("Ben");

ana.Deposit(100);
ana.Deposit(50);
ben.Deposit(20);

Console.WriteLine(ana.Describe());
Console.WriteLine(ben.Describe());

class BankAccount
{
    private string owner;          // fields: each object has its own
    private decimal balance;

    public BankAccount(string owner)
    {
        this.owner = owner;         // this.owner is the field, owner the parameter
    }

    public void Deposit(decimal amount)
    {
        balance += amount;          // same as this.balance += amount
    }

    public string Describe() => $"{owner}: {balance:0.00}";
}
Outputcompiled & run with real C#
Ana: 150.00
Ben: 20.00
Your turn

Add a Withdraw(decimal amount) method that refuses (prints a message and changes nothing) when the balance would go below zero.

Where objects live
A class is a reference type. new BankAccount("Ana") allocates the object on the heap and gives you a reference to it; var ana holds that reference. Copying ana into another variable copies the reference, not the object. The garbage collector frees the object once nothing refers to it (Module 00).
02

Properties, access modifiers and encapsulation

Encapsulation means an object guards its own data: fields stay private, and the outside world goes through members that can check what it asks for. In C# those members are properties: they look like fields at the call site (account.Balance) but run code in a get and set accessor.

ModifierVisible to
publicAny code
privateOnly code inside the same type (the default for members)
protectedThe type and classes derived from it (Module 06)
internalAny code in the same assembly (project); the default for top-level types
protected internal / private protectedCombinations of the two, used mostly in libraries
C#Program.cs
var t = new Thermostat();
t.Celsius = 21.5;
Console.WriteLine($"{t.Celsius} C = {t.Fahrenheit} F");

t.Celsius = -500;                     // rejected by the setter
Console.WriteLine($"still {t.Celsius} C");
Console.WriteLine($"changed {t.Changes} times");

class Thermostat
{
    private double celsius = 20;     // backing field

    public double Celsius
    {
        get => celsius;
        set
        {
            if (value < -273.15) { Console.WriteLine($"ignored {value}"); return; }
            celsius = value;
            Changes++;
        }
    }

    public double Fahrenheit => Celsius * 9 / 5 + 32;   // computed, read-only

    public int Changes { get; private set; }            // auto-property, private setter
}
Outputcompiled & run with real C#
21.5 C = 70.7 F
ignored -500
still 21.5 C
changed 1 times

value is the implicit parameter of every set accessor. Changes is an auto-property: the compiler writes the hidden field for you.

Error you will hit

CS0122: reaching a private member from outside

C#
var acct = new Account();
acct.balance = 1_000_000;

class Account
{
    private decimal balance;
    public decimal Balance => balance;
}
Program.cs(2,6): error CS0122: 'Account.balance' is inaccessible due to its protection level
Why the compiler said that

balance is private, so only code written inside Account may touch it. This is the whole point: the class decides how its balance changes, and no caller can skip that logic.

The fix

Go through the public surface the class offers. If a change is legitimate, the class should expose a method for it (such as Deposit) that enforces its rules.

C#
var acct = new Account();
acct.Deposit(100);
Console.WriteLine(acct.Balance);

class Account
{
    private decimal balance;
    public decimal Balance => balance;
    public void Deposit(decimal amount)
    {
        if (amount <= 0) throw new ArgumentOutOfRangeException(nameof(amount));
        balance += amount;
    }
}

init-only and required properties

An init accessor lets a property be set while the object is being created (in a constructor or an object initializer) and never again: immutable after construction without writing a constructor. required goes one step further and makes the compiler reject any object creation that forgets to set it.

C#Program.cs
var order = new Order { Id = 42, Customer = "Ana", Note = "leave at door" };
Console.WriteLine($"#{order.Id} for {order.Customer}: {order.Note}");

var bare = new Order { Id = 43, Customer = "Ben" };   // Note is optional
Console.WriteLine($"#{bare.Id} note is empty: {bare.Note == ""}");

class Order
{
    public required int Id { get; init; }
    public required string Customer { get; init; }
    public string Note { get; init; } = "";
}
Outputcompiled & run with real C#
#42 for Ana: leave at door
#43 note is empty: True
Error you will hit

CS8852: assigning an init-only property after creation

C#
var order = new Order { Id = 42 };
order.Id = 7;

class Order
{
    public int Id { get; init; }
}
Program.cs(2,1): error CS8852: Init-only property or indexer 'Order.Id' can only be assigned in an object initializer, or on 'this' or 'base' in an instance constructor or an 'init' accessor.
Why the compiler said that

init means "settable during construction only". Once new Order { ... } has finished, the object is frozen as far as Id is concerned.

The fix

Set it in the object initializer. If you genuinely need a changed copy, make Order a record and use order with { Id = 7 } (see the records lesson below).

C#
var order = new Order { Id = 7 };
Console.WriteLine(order.Id);

class Order
{
    public int Id { get; init; }
}
Error you will hit

CS9035: forgetting a required property

C#
var order = new Order { Id = 42 };

class Order
{
    public required int Id { get; init; }
    public required string Customer { get; init; }
}
Program.cs(1,17): error CS9035: Required member 'Order.Customer' must be set in the object initializer or attribute constructor.
Why the compiler said that

Both properties are required, and the initializer sets only Id. The compiler catches the half-built object at the line that creates it, instead of letting a null customer surface much later.

The fix

Set every required member in the initializer.

C#
var order = new Order { Id = 42, Customer = "Ana" };
Console.WriteLine(order.Customer);

class Order
{
    public required int Id { get; init; }
    public required string Customer { get; init; }
}
03

Constructors, primary constructors and object initializers

A constructor is the method that runs when you write new. It has the class's name and no return type, and its job is to leave the object in a valid state. A class can have several constructors (overloads); one can call another with : this(...) so the setup logic lives in one place. If you write no constructor, C# gives you a parameterless one that does nothing.

C#Program.cs
var a = new Rectangle(3, 4);
var b = new Rectangle(5);            // a square, via : this(side, side)
var c = new Rectangle();             // default 1 x 1

foreach (var r in new[] { a, b, c })
    Console.WriteLine($"{r.Width} x {r.Height} = {r.Area}");

class Rectangle
{
    public int Width { get; }
    public int Height { get; }
    public int Area => Width * Height;

    public Rectangle(int width, int height)
    {
        if (width <= 0 || height <= 0)
            throw new ArgumentException("sides must be positive");
        Width = width;
        Height = height;
    }

    public Rectangle(int side) : this(side, side) { }

    public Rectangle() : this(1) { }
}
Outputcompiled & run with real C#
3 x 4 = 12
5 x 5 = 25
1 x 1 = 1

A primary constructor (C# 12) puts the parameters on the class line itself: class Greeter(string greeting). The parameters are in scope in the whole class body, so you can use them in methods or copy them into properties. Unlike a record (below), a class with a primary constructor does not get public properties automatically: the parameters are private to the class.

An object initializer sets accessible properties right after the constructor runs: new Point { X = 1, Y = 2 }. It is shorthand for calling the constructor and then assigning each property, and it is the only way (besides a constructor) to set init properties.

C#Program.cs
var hi = new Greeter("Hello");
Console.WriteLine(hi.Greet("Ana"));
Console.WriteLine(hi.Greet("Ben"));
Console.WriteLine(hi.Count);

var card = new Card { Title = "Intro", Pages = 3 };
Console.WriteLine($"{card.Title} ({card.Pages} pages)");

class Greeter(string greeting)       // primary constructor
{
    public int Count { get; private set; }

    public string Greet(string name)
    {
        Count++;
        return $"{greeting}, {name}!";   // parameter captured by the class
    }
}

class Card
{
    public string Title { get; set; } = "Untitled";
    public int Pages { get; set; }
}
Outputcompiled & run with real C#
Hello, Ana!
Hello, Ben!
2
Intro (3 pages)
Your turn

Give Greeter a second constructor public Greeter() : this("Hi") { } and create one with new Greeter().

Error you will hit

CS7036: no parameterless constructor exists any more

C#
var p = new Person();

class Person
{
    public string Name { get; }
    public Person(string name) => Name = name;
}
Program.cs(1,13): error CS7036: There is no argument given that corresponds to the required parameter 'name' of 'Person.Person(string)'
Why the compiler said that

The free parameterless constructor exists only while you write none yourself. The moment Person(string name) exists, new Person() has no matching constructor, and the compiler reports the missing name argument.

The fix

Pass the argument, or add a parameterless constructor if an object without a name is really valid.

C#
var p = new Person("Ana");
Console.WriteLine(p.Name);

class Person
{
    public string Name { get; }
    public Person(string name) => Name = name;
}
04

Static members and static classes

A static member belongs to the class itself, not to any one object. There is exactly one copy of a static field, shared by every instance, and you reach it through the class name: Ticket.Issued. Static methods cannot use this or instance fields, because there is no object. A static class can contain only static members and cannot be instantiated; Math and Console are static classes.

C#Program.cs
var t1 = new Ticket("concert");
var t2 = new Ticket("museum");
var t3 = new Ticket("concert");

Console.WriteLine($"{t1.Number} {t2.Number} {t3.Number}");
Console.WriteLine($"issued: {Ticket.Issued}");
Console.WriteLine(Units.KmToMiles(42.195).ToString("0.00"));

class Ticket
{
    public static int Issued { get; private set; }   // one shared counter

    public string Number { get; }

    public Ticket(string eventName)
    {
        Issued++;
        Number = $"{eventName[..3].ToUpper()}-{Issued:000}";
    }
}

static class Units
{
    public const double MilesPerKm = 0.621371;
    public static double KmToMiles(double km) => km * MilesPerKm;
}
Outputcompiled & run with real C#
CON-001 MUS-002 CON-003
issued: 3
26.22
Static state is global state
A static field is effectively a global variable: shared by every object, every request in a web app and every test. Use static for constants, pure helper methods and caches you mean to share. Do not put per-user or per-request data in a static field; in a web server that is how one user sees another's data.
Error you will hit

CS0120: using an instance member from a static method

C#
Console.WriteLine(Counter.Report());

class Counter
{
    private int count = 5;
    public static string Report() => $"count is {count}";
}
Program.cs(6,50): error CS0120: An object reference is required for the non-static field, method, or property 'Counter.count'
Why the compiler said that

Report is static, so it runs without any Counter object, but count is an instance field: each object has its own. With no object, there is no count to read.

The fix

Either make Report an instance method and call it on an object, or make the data static too if it really is shared.

C#
var c = new Counter();
Console.WriteLine(c.Report());

class Counter
{
    private int count = 5;
    public string Report() => $"count is {count}";
}
05

Records: value equality and with-expressions

A record is a type built for holding data. One line, record Money(decimal Amount, string Currency);, gives you a constructor, public init-only properties, deconstruction, a readable ToString and, most importantly, value equality: two records are equal when all their properties are equal, even if they are different objects. Two ordinary class instances are equal only when they are the very same object.

C#Program.cs
var a = new Money(10m, "USD");
var b = new Money(10m, "USD");
Console.WriteLine(a);
Console.WriteLine($"records equal: {a == b}");

var c1 = new MoneyClass(10m, "USD");
var c2 = new MoneyClass(10m, "USD");
Console.WriteLine($"classes equal: {c1 == c2}");

var raised = a with { Amount = 15m };   // copy with one property changed
Console.WriteLine($"{a.Amount} -> {raised.Amount} {raised.Currency}");

var (amount, currency) = raised;         // deconstruction for free
Console.WriteLine($"{amount} {currency}");

record Money(decimal Amount, string Currency);

class MoneyClass(decimal amount, string currency)
{
    public decimal Amount { get; } = amount;
    public string Currency { get; } = currency;
}
Outputcompiled & run with real C#
Money { Amount = 10, Currency = USD }
records equal: True
classes equal: False
10 -> 15 USD
15 USD
Your turn

Add a method to Money: public Money Add(Money other) => this with { Amount = Amount + other.Amount }; and print a.Add(b).

  • record (or record class) is a reference type; record struct is a value type with the same conveniences.
  • Records are immutable by default because positional properties are init-only. with is how you "change" one: it copies and overrides.
  • You can add methods, extra properties and validation to a record like any class.
  • Use records for DTOs, API request/response shapes, messages, value objects (money, coordinates) and dictionary keys. Use a class for things with identity and changing state (an account, a connection).
with makes a shallow copy
If a record holds a List<T>, with copies the reference to the list, not the list. Both records then share and mutate the same list. Prefer immutable collections (or arrays you never change) inside records.
06

Structs versus classes: copy semantics

A struct is a value type: a variable holds the data itself, and assignment copies all of it. A class is a reference type: a variable holds a reference, and assignment copies the reference, so two variables can point at one object. int, double, bool, DateTime and tuples are structs; string, arrays and List<T> are classes.

VisualizeCopying a struct versus copying a classStep 1 / 7
var p1 = new PointS { X = 1 };
var p2 = p1;
p2.X = 99;
var r1 = new PointC { X = 1 };
var r2 = r1;
r2.X = 99;
Console.WriteLine($"{p1.X} {r1.X}");
struct PointS { public int X; }
class PointC { public int X; }
Line 1

p1 holds a struct directly: the X value lives in the variable.

Variables now
p1.X1
All 7 steps as a table
StepLineWhat happenedVariables now
11p1 holds a struct directly: the X value lives in the variable.p1.X = 1
22Assigning a struct copies every field. p2 is an independent copy.p1.X = 1 p2.X = 1
33Changing the copy leaves the original alone.p1.X = 1 p2.X = 99
44r1 holds a reference to a new object on the heap.r1 -> obj.X = 1
55Assigning a class copies the reference only: r2 points at the same object.r1 -> obj.X = 1 r2 -> obj.X = 1
66Writing through r2 changes the one shared object, so r1 sees it too.r1 -> obj.X = 99 r2 -> obj.X = 99
77The struct kept its value; the class did not.
C#Program.cs
var points = new List<PointS> { new PointS(1, 2) };
var first = points[0];            // a COPY of the struct
first.X = 50;
Console.WriteLine(points[0].X);   // the list is unchanged

points[0] = first;                // write the copy back
Console.WriteLine(points[0].X);

PointS origin = default;          // all fields zero, never null
Console.WriteLine($"({origin.X}, {origin.Y})");

struct PointS(int x, int y)
{
    public int X = x;
    public int Y = y;
}
Outputcompiled & run with real C#
1
50
(0, 0)

Reading a struct out of a list or dictionary gives you a copy. This surprises everyone once; it is one reason mutable structs are discouraged.

structclass
KindValue typeReference type
Assignment / passingCopies all the dataCopies the reference
Can be nullNo (unless Nullable<T>, e.g. int?)Yes
InheritanceCannot inherit or be inherited (can implement interfaces)Single inheritance
Default equalityField by fieldSame object (reference equality)
Use forSmall (up to about 16 bytes), immutable values: coordinates, colours, moneyAlmost everything else
In real jobs
Most C# code you write will be classes and records. Declare a struct only for small, immutable values, ideally readonly struct or readonly record struct so nobody can mutate a copy by mistake. Large structs make every assignment and method call copy the whole thing.
07

ToString, Equals and GetHashCode

Every type inherits three methods from object that you often override. ToString() is what string interpolation and Console.WriteLine call; for a class the default prints only the type name. Equals(object) decides whether two objects count as the same value; for a class the default is "same object". GetHashCode() picks the bucket in a Dictionary or HashSet, and it must agree with Equals: equal objects must return equal hash codes.

C#Program.cs
var a = new Isbn("978-0134494166");
var b = new Isbn("9780134494166");      // same book, no dashes

Console.WriteLine(a);
Console.WriteLine($"Equals: {a.Equals(b)}");

var shelf = new HashSet<Isbn> { a };
Console.WriteLine($"shelf has b: {shelf.Contains(b)}");

class Isbn
{
    public string Digits { get; }

    public Isbn(string text) => Digits = text.Replace("-", "");

    public override string ToString() => $"ISBN {Digits}";

    public override bool Equals(object? obj) =>
        obj is Isbn other && other.Digits == Digits;

    public override int GetHashCode() => Digits.GetHashCode();
}
Outputcompiled & run with real C#
ISBN 9780134494166
Equals: True
shelf has b: True
Your turn

Delete the GetHashCode override (keep Equals). The compiler warns (CS0659). Does shelf.Contains(b) still print True? Why not?

For several fields, combine them with HashCode.Combine(Name, Year) rather than inventing arithmetic. Or skip the ceremony: a record generates correct Equals, GetHashCode, ==, != and ToString from its properties. Override by hand only when equality is not "all fields equal", as with the dashes above.

Never use mutable fields in GetHashCode
If an object is in a HashSet and you change a field its hash code depends on, it is now in the wrong bucket: Contains returns false for an object that is plainly in the set. Hash only on data that never changes after construction.
08

Nullable reference types and NullReferenceException

Any reference-type variable can hold null, meaning "no object". Calling a member on null throws a NullReferenceException, historically the most common crash in .NET. Nullable reference types (on by default in new projects and in file-based apps) make the compiler track it: string means "never null", string? means "might be null", and the compiler warns when you dereference a string? without checking.

C#Program.cs
string? nickname = FindNickname("ben");

// Three safe ways to use a string? value
if (nickname is not null)
    Console.WriteLine($"length {nickname.Length}");

int? length = nickname?.Length;                                  // ?. short-circuits to null
Console.WriteLine($"has a length: {length.HasValue}");
Console.WriteLine($"shown as: {nickname ?? "(none)"}");       // ?? supplies a fallback

var ana = new User("Ana", FindNickname("ana"));
Console.WriteLine(ana.Display);

static string? FindNickname(string user) => user == "ana" ? "Annie" : null;

class User(string name, string? nickname)
{
    public string Display => nickname is null ? name : $"{name} ({nickname})";
}
Outputcompiled & run with real C#
has a length: False
shown as: (none)
Ana (Annie)
Error you will hit

NullReferenceException: calling a member on null

C#
var names = new Dictionary<int, string?> { [1] = "Ana", [2] = null };
foreach (var (id, name) in names)
    Console.WriteLine($"{id}: {name.ToUpper()}");
Program.cs(3,32): warning CS8602: Dereference of a possibly null reference.
Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.
   at Program.<Main>$(String[] args) in Program.cs:line 3
Why the compiler said that

Entry 2 holds null. The first iteration works, then name.ToUpper() runs on null and the runtime throws. Look at the build output too: the compiler printed warning CS8602: Dereference of a possibly null reference for that exact line. Nullable warnings are the compiler predicting this crash; treat them as errors (<TreatWarningsAsErrors> or at least <WarningsAsErrors>nullable</WarningsAsErrors> in the project file).

The fix

Decide what a missing name means and handle it: ?. plus ?? for a fallback, or an explicit if. Use the null-forgiving ! operator only when you know better than the compiler, never to silence it.

C#
var names = new Dictionary<int, string?> { [1] = "Ana", [2] = null };
foreach (var (id, name) in names)
    Console.WriteLine($"{id}: {name?.ToUpper() ?? "(no name)"}");
Warnings, not errors
Nullable reference types are a compile-time analysis only: string and string? are the same type at runtime, and the compiler reports warnings, not errors, so the code above still builds and crashes. That is why teams turn the nullable warnings into errors in CI.
Class
A reference type that bundles data (fields, properties) and behaviour (methods).
Object / instance
One value created from a class with new; it has its own copy of every instance field.
Property
A member that looks like a field but runs get/set accessor code; auto-properties have a compiler-written backing field.
Encapsulation
Keeping data private and exposing only members that enforce the object's rules.
init / required
init: settable only during construction. required: must be set when the object is created.
Primary constructor
Constructor parameters written on the type line (class C(int x)), in scope throughout the body.
Static member
Belongs to the type, not an instance; one shared copy.
Record
A type with compiler-generated value equality, ToString, deconstruction and with-expressions.
Value type / reference type
Structs are copied on assignment; classes share one object through copied references.
Nullable reference types
Compile-time tracking of which references may be null (string?) and which may not (string).
Quick check

record Point(int X, int Y); What does new Point(1, 2) == new Point(1, 2) evaluate to, and what would it be if Point were a plain class with the same properties?

Quick check

A struct Counter { public int N; } is stored in a List<Counter>. You write var c = list[0]; c.N++;. What is list[0].N afterwards?

Frequently asked questions

What is the difference between a class and a record in C#?
Both are reference types, but a record compares by value (two records with the same property values are equal), generates ToString and deconstruction, supports with-expressions, and its positional properties are init-only. Use records for data and classes for objects with identity and changing state.
When should I use a struct instead of a class in C#?
For small, immutable values that are copied often, such as coordinates or money, ideally as a readonly struct or readonly record struct. Structs are copied on every assignment, cannot be null and cannot inherit, so most types should be classes or records.
How do I avoid NullReferenceException in C#?
Keep nullable reference types enabled, declare possibly-missing values as string? and handle them with if checks, ?. and ??, and treat the compiler's nullable warnings (such as CS8602) as errors so they fail the build before they crash in production.

Finish the C# handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.