Classes and Objects
Object-oriented C#: classes with fields, properties and methods, constructors and primary constructors, object initializers, required and init-only properties, and static members.
Defining a class
A class is a blueprint for objects with the same shape and behavior. The general ideas (objects, encapsulation, inheritance, polymorphism) are explained in Object-Oriented Concepts; this page shows how C# does it. C# is object-oriented to the core: every method lives in a type, and even the top-level statements of a program are compiled into a hidden class.
A class lists its members. A field is a variable
that belongs to the object. A
method
is a function that belongs to the class and works on the object's
data. You create an object with new, and reach its
members with a dot.
var delft = new Meter();
delft.Id = "MTR-0042";
delft.City = "Delft";
delft.AddReading(412.5);
delft.AddReading(415.8);
Console.WriteLine($"{delft.Id} in {delft.City}: {delft.Usage():F1} kWh");
class Meter
{
public string Id = "";
public string City = "";
private List<double> readings = [];
public void AddReading(double kwh)
{
readings.Add(kwh);
}
public double Usage()
{
return readings[^1] - readings[0];
}
}
MTR-0042 in Delft: 3.3 kWh
The class is declared below the top-level statements: in a
single-file program, types always come after the code that runs.
Inside a method, the fields of the current object are simply
available by name (readings); you may write
this.readings to make that explicit. By convention,
class, method and public member names use PascalCase, and private
fields and local variables use camelCase (many teams prefix private
fields with an underscore: _readings).
Variables hold references
A class is a
reference type: the
object lives on the heap, and a variable holds a reference to it.
Assigning one variable to another copies the reference, not the
object, so both names point to the same meter. And
== on two class instances asks "is this the same
object?", not "do they contain the same data?"
var a = new Meter { Id = "MTR-0042" };
var b = a;
b.Id = "MTR-0099";
Console.WriteLine(a.Id);
var c = new Meter { Id = "MTR-0099" };
Console.WriteLine(a == b);
Console.WriteLine(a == c);
Console.WriteLine(a);
class Meter
{
public string Id { get; set; } = "";
}
MTR-0099 True False Meter
a and c hold equal data but are two
different objects, so a == c is False.
Printing an object calls its ToString() method, which
by default only returns the type name. When you want comparison by
content and a readable printout for free, use a record (see
Records, Structs and Enums). The memory picture behind references is on
Memory Management.
Access modifiers
An access modifier is the keyword in front of a type or member that decides which code may use it. It is how C# enforces encapsulation: the compiler refuses access from outside, so a class can protect its data and change its internals later without breaking callers.
| Modifier | Who can use the member |
|---|---|
public |
Any code. |
private |
Only code inside the same class. The default for members. |
protected |
The class and classes derived from it (see Inheritance). |
internal |
Any code in the same assembly (the same project's compiled DLL or executable). The default for classes declared outside another class. |
protected internal,
private protected
|
Combinations: "derived classes or same assembly", and "derived classes in the same assembly". Rarely needed. |
Because private is the default, a member without a
modifier is hidden. Leaving it out is legal, but writing
private explicitly makes the intent clear to the next
reader.
var meter = new Meter();
Console.WriteLine(meter.serial);
class Meter
{
string serial = "KE-7781-0042";
}
example.cs(2,25): error CS0122: 'Meter.serial' is inaccessible due to its protection level
Properties
Public fields are rare in C#. Data that other code may see is
exposed through a
property
instead: a member that looks like a field from the outside but has a
get accessor (run when the value is read) and usually a
set accessor (run when it is assigned). The getter and
setter idea is the same as in
JavaScript; in C# it is the normal way to publish data.
Auto-implemented properties
An
auto-implemented property
(auto-property) is a property written as just
{ get; set; }: the compiler creates a hidden backing
field to store the value. Each accessor can have its own access
modifier, so { get; private set; } means "anyone may
read, only this class may change". Leaving out the setter ({ get; }) gives a property that can only be set in the constructor.
var ticket = new Ticket("INC-40112", "No power in Leiden");
ticket.Assign("Amara Okafor");
Console.WriteLine($"{ticket.Number}: {ticket.Status}, {ticket.AssignedTo}");
ticket.Status = "Closed";
class Ticket
{
public Ticket(string number, string summary)
{
Number = number;
Summary = summary;
}
public string Number { get; }
public string Summary { get; }
public string Status { get; private set; } = "New";
public string? AssignedTo { get; private set; }
public void Assign(string engineer)
{
AssignedTo = engineer;
Status = "Assigned";
}
}
example.cs(4,1): error CS0272: The property or indexer 'Ticket.Status' cannot be used in this context because the set accessor is inaccessible
The compiler refuses the last top-level line: the status can only
change through Assign, which keeps it consistent with
AssignedTo. That is encapsulation at work. The
= "New" after a property is an initial value. The
? in string? says the property may be null
until a ticket is assigned (see
Nullable Reference Types and Null Safety).
Properties with logic
Why not just make the field public? Because a property can grow
logic later without changing its callers: validation, a computed
value, a change notification. Code that reads
tariff.PricePerKwh keeps working when the property
starts checking its input. In C# 14 the keyword
field refers to the compiler-generated backing field,
so you can add a check to one accessor and keep the other automatic.
var t = new Tariff();
t.Name = "Green Fixed";
t.PricePerKwh = 0.31m;
Console.WriteLine($"{t.Name}: {t.PricePerKwh:F2} per kWh");
t.PricePerKwh = -0.05m;
class Tariff
{
public string Name { get; set; } = "";
public decimal PricePerKwh
{
get => field;
set
{
if (value < 0)
throw new ArgumentOutOfRangeException(nameof(value), "Price cannot be negative.");
field = value;
}
}
}
Green Fixed: 0.31 per kWh Unhandled exception. System.ArgumentOutOfRangeException: Price cannot be negative. (Parameter 'value')
Inside a set accessor, value is the value
being assigned. Before C# 14 you declared the backing field yourself
(private decimal _price;) and wrote
get => _price;; you will see that style in most
existing code. A property without a setter can also be
computed each time it is read, written as an
expression-bodied member:
public decimal PriceWithVat => PricePerKwh * 1.21m;
Constructors
A
constructor
is a special method with the same name as the class and no return
type. It runs once, when new creates the object, and
its job is to leave the object in a valid state: check the arguments
and set every member that needs a value. A class can have several
constructors with different parameters (overloading, see
Methods and Parameters);
: this(...) lets one constructor call another, so the
checks live in one place.
var home = new Customer("Mei Chen", "Utrecht");
var unknown = new Customer("Ravi Kumar");
Console.WriteLine(home.Describe());
Console.WriteLine(unknown.Describe());
class Customer
{
public string Name { get; }
public string City { get; }
public Customer(string name, string city)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("A customer needs a name.", nameof(name));
Name = name;
City = city;
}
public Customer(string name) : this(name, "unknown")
{
}
public string Describe() => $"{Name} ({City})";
}
Mei Chen (Utrecht) Ravi Kumar (unknown)
A class without any constructor gets an invisible parameterless one,
which is why new Meter() worked in the first example.
As soon as you write a constructor with parameters, that default one
disappears, and new Customer() becomes a compile error:
the class now insists on a name.
Primary constructors
A primary constructor (C# 12) puts the constructor parameters directly after the class name. The parameters are in scope in the whole class body: you can use them to initialize properties, or use them directly in methods. It removes the repetition of declaring, listing and assigning each value.
var meter = new Meter("MTR-0042", 412.5);
meter.Add(3.3);
meter.Add(1.2);
Console.WriteLine($"{meter.Id}: {meter.Reading:F1} kWh");
class Meter(string id, double start)
{
public string Id { get; } = id;
public double Reading { get; private set; } = start;
public void Add(double kwh) => Reading += kwh;
}
MTR-0042: 417.0 kWh
Unlike a record's parameters, the parameters of a class's primary constructor do not become public properties. When a method uses one directly, the compiler stores it in a hidden private field, and that field is mutable:
var meter = new Meter("MTR-0042", 412.5);
meter.Add(3.3);
Console.WriteLine(meter.Report());
class Meter(string id, double reading)
{
public void Add(double kwh) => reading += kwh;
public string Report() => $"{id}: {reading:F1} kWh";
}
MTR-0042: 415.8 kWh
Object initializers
An object initializer is
the { Name = value, ... } block after new:
it sets accessible properties by name right after the constructor
has run. It reads like a form being filled in, and you set only the
properties you need; the rest keep their initial values.
var ticket = new Ticket
{
Number = "INC-40112",
Summary = "No power in Leiden",
Priority = 2,
};
Console.WriteLine($"{ticket.Number} P{ticket.Priority}: {ticket.Summary}");
class Ticket
{
public string Number { get; set; } = "";
public string Summary { get; set; } = "";
public int Priority { get; set; } = 3;
public List<string> Notes { get; } = [];
}
INC-40112 P2: No power in Leiden
When the type is clear from the left-hand side, the
target-typed form leaves the class name out:
Ticket t = new() { Number = "INC-40112" };. A trailing
comma after the last property is allowed. With
{ get; set; } properties, though, anyone can still
change the values later. The next two features close that gap.
Init-only properties
An init-only property has
an init accessor instead of set (C# 9). It
can be assigned in an object initializer or a constructor, and after
that it is read-only. That gives you the readable initializer syntax
and an object that cannot be changed afterward: a meter
reading, once taken, should not change.
var reading = new Reading { MeterId = "MTR-0042", Kwh = 412.5 };
Console.WriteLine($"{reading.MeterId}: {reading.Kwh} kWh");
reading.Kwh = 0;
class Reading
{
public string MeterId { get; init; } = "";
public double Kwh { get; init; }
}
example.cs(3,1): error CS8852: Init-only property or indexer 'Reading.Kwh' can only be assigned in an object initializer, or on 'this' or 'base' in an instance constructor or an 'init' accessor.
Required members
An object initializer cannot force anyone to set a property: leave
out Email and the object is created anyway, with an
empty or null value. A
required member, marked with
the required modifier (C# 11), turns that into a
compile error: every new must set it, in the
initializer or through a constructor marked
[SetsRequiredMembers].
var customer = new Customer { Name = "Mei Chen" };
Console.WriteLine(customer.Name);
class Customer
{
public required string Name { get; init; }
public required string Email { get; init; }
}
example.cs(1,20): error CS9035: Required member 'Customer.Email' must be set in the object initializer or attribute constructor.
Set both, and it compiles. Optional properties simply stay without
required:
var c = new Customer { Name = "Mei Chen", Email = "mei.chen@example.com" };
Console.WriteLine($"{c.Name} <{c.Email}>, newsletter: {c.Newsletter}");
class Customer
{
public required string Name { get; init; }
public required string Email { get; init; }
public bool Newsletter { get; init; }
}
Mei Chen <mei.chen@example.com>, newsletter: False
required checks presence, not content:
Email = "" still compiles. Validation of values belongs
in a constructor or a property setter. The combination
required ... { get; init; } is the modern way to write
a small data class that must be complete and cannot change. When you
also want value equality, a record does all of that in one line.
Static members
Most members belong to an object: each meter has its own
Id. A static member belongs to the class
itself and is shared by all objects; you reach it through the class
name (Ticket.Created), not through an instance. This is
the C# form of a
static method
or class attribute.
var first = new Ticket("No power in Leiden");
var second = new Ticket("Smart meter offline");
Console.WriteLine(first.Number);
Console.WriteLine(second.Number);
Console.WriteLine(Ticket.Created);
class Ticket
{
private static int nextNumber = 40112;
public static int Created { get; private set; }
public string Number { get; }
public string Summary { get; }
public Ticket(string summary)
{
Number = $"INC-{nextNumber++}";
Summary = summary;
Created++;
}
}
INC-40112 INC-40113 2
A static method has no this: it cannot touch instance
members unless it is given an object. A
static class can contain only static members and can
never be instantiated; it is the place for helper methods and
constants that do not need an object, like the built-in
Math and Console classes.
Console.WriteLine(Energy.KwhToMj(10));
Console.WriteLine(Energy.VatRate);
Console.WriteLine(Energy.WithVat(100m).ToString("F2"));
static class Energy
{
public const decimal VatRate = 0.21m;
public static readonly DateOnly TariffStart = new(2026, 1, 1);
public static double KwhToMj(double kwh) => kwh * 3.6;
public static decimal WithVat(decimal amount) => amount * (1 + VatRate);
}
36 0.21 121.00
A const is a compile-time
constant: its value is baked into every place that uses it, so it suits
values that will never change (and only numbers, characters,
strings, bool and enums can be constants). A
static readonly field is set once at run time and can
hold any type, such as a DateOnly.
ToString and printing objects
Every class inherits from object, and with it a
ToString() method that returns the type name. Override
it to give objects a useful text form;
Console.WriteLine
and string interpolation call it automatically. The keyword
override is explained on
Inheritance, Interfaces and Polymorphism.
var meter = new Meter { Id = "MTR-0042", Kwh = 412.5 };
Console.WriteLine(meter);
Console.WriteLine($"Last reading: {meter}");
class Meter
{
public string Id { get; set; } = "";
public double Kwh { get; set; }
public override string ToString() => $"{Id} ({Kwh:F1} kWh)";
}
MTR-0042 (412.5 kWh) Last reading: MTR-0042 (412.5 kWh)
ToString() is the C# counterpart of Python's
__str__. Keep it short and free of side effects: debuggers and log
statements call it too.
Designing a class
A few habits keep classes easy to use and hard to misuse:
- Make invalid states impossible. Check arguments in the constructor or setter, and give properties a valid starting value, so an object is correct from the moment it exists.
-
Expose as little as possible. Start with
privatefields and get-only orprivate setproperties; widen access only when another class really needs it. -
Change state through methods with a meaning.
ticket.Assign("Amara Okafor")says what happens and keeps related values consistent; two separate property assignments do not. -
Prefer immutable data. For values that should not
change after creation, use get-only or
initproperties, or a record. -
One class, one job. A
Meterthat also sends invoices and writes log files is hard to test and change; split responsibilities over small classes.