Classes, structs, and records
C# is fully object-oriented: classes are first-class user-defined reference types, with constructors, destructors, properties, events, indexers, methods, and the full surface of inheritance and interfaces. The language additionally provides structs (value types), records (concise reference and value types with structural equality), and interfaces (which since C# 8 may carry default implementations). The four constructs together cover the conventional object-modelling axes; choosing among them is one of the principal design decisions in any non-trivial C# codebase.
This page covers the construction surface — class definitions, constructors, properties, members, access control, inheritance, virtual dispatch, structs, and records — and the conventions for using each idiomatically. Generics are treated in Generics; the value-vs-reference partition is in Reference and value types.
Classes
A class is a user-defined reference type:
public class Counter {
private int count = 0;
public Counter() { }
public Counter(int initial) {
count = initial;
}
public void Increment() {
count++;
}
public int Value => count;
}
Counter c = new Counter(10);
c.Increment();
int v = c.Value; // 11
Members may be:
- Fields — variables that each instance carries.
- Properties — encapsulated getter/setter pairs (covered below).
- Methods — instance-callable functions.
- Constructors — special methods that initialise instances.
- Finalizers — special methods that run before reclamation (rare).
- Events — member subscriptions (covered in Methods and delegates).
- Indexers —
this[i]-style accessors. - Operators — overloaded operators.
- Static members — members associated with the type, not with instances.
- Nested types — class, struct, enum, or interface declarations inside another type.
Properties
A property is an encapsulated pair of accessors:
public class Person {
private string name;
public string Name {
get => name;
set => name = value ?? throw new ArgumentNullException(nameof(value));
}
}
The value keyword (in the setter) is the implicit name of the assigned value. Properties look like fields at the call site (p.Name = "alice", string n = p.Name) but compile to method calls.
Auto-properties
When the property’s accessors merely store and retrieve a backing field, the auto-property form generates the field implicitly:
public class Person {
public string Name { get; set; } = "";
public int Age { get; set; }
}
The compiler synthesises the backing field. The property is otherwise indistinguishable from the manual form.
Init-only setters
C# 9 introduced init accessors: setters that may be called only during object initialisation:
public class Document {
public string Title { get; init; } = "";
public string Body { get; init; } = "";
}
var d = new Document { Title = "draft", Body = "content" };
// d.Title = "final"; // ERROR: init-only
The mechanism admits immutable-after-construction semantics with the convenience of object-initialiser syntax. It is the conventional choice for value-like data classes that are not records.
Required members
C# 11 introduced required: members that must be initialised in any object initialiser:
public class Document {
public required string Title { get; init; }
public required string Body { get; init; }
}
var d = new Document { Title = "x", Body = "y" }; // OK
var e = new Document { Title = "x" }; // ERROR: Body required
The mechanism is the conventional way to express “this property must be set” without adding a non-default constructor.
Read-only properties
A property with only a getter is read-only:
public class Counter {
private int count;
public int Count => count; // get only
public int OtherCount { // get only
get { return count; }
}
}
The expression-body form => is conventional for getters that return a single expression.
Indexers
An indexer is a member that admits this[i]-style access:
public class Buffer {
private byte[] data = new byte[1024];
public byte this[int index] {
get => data[index];
set => data[index] = value;
}
public byte this[Range range] { // C# 8: range indexer
get => data[range][0];
}
}
var b = new Buffer();
b[0] = 42;
byte v = b[0];
Indexers may be overloaded by parameter type and may have multiple parameters (this[int x, int y]). The conventional use is on collection-like types and on byte buffers.
Constructors
A constructor initialises an instance:
public class Document {
public string Title { get; }
public string Body { get; }
public Document() : this("untitled", "") { } // delegating
public Document(string title) : this(title, "") { }
public Document(string title, string body) {
Title = title;
Body = body;
}
}
The : this(...) form chains to another constructor of the same class; the body of the targeted constructor runs first, then the body of the original.
The : base(...) form chains to a base-class constructor:
public class Derived : Base {
public Derived(int n) : base(n * 2) {
// ...
}
}
If neither this nor base is specified, the compiler implicitly calls the parameterless base constructor.
Primary constructors
C# 12 introduced primary constructors: parameters declared on the class itself, accessible throughout the body:
public class Counter(int initial = 0) {
private int count = initial;
public void Increment() => count++;
public int Value => count;
}
var c = new Counter(10);
The mechanism is concise; it admits the parameters as values within the class body without ceremony. Primary constructors interact with conventional constructors:
public class Counter(int initial = 0) {
private int count = initial;
public Counter(int initial, int step) : this(initial) {
// ...
}
}
The original primary constructor parameters are accessible only as values; they are not exposed as properties unless explicitly declared.
Inheritance
A class may inherit from another:
public class Animal {
public virtual void Speak() => Console.WriteLine("some sound");
public virtual string Name() => "animal";
}
public class Dog : Animal {
public override void Speak() => Console.WriteLine("woof");
public override string Name() => "dog";
}
public class Cat : Animal {
public override void Speak() => Console.WriteLine("meow");
}
C# admits single inheritance from classes plus multiple inheritance from interfaces. The conventional syntax: class Derived : BaseClass, IInterface1, IInterface2.
Virtual, override, sealed, abstract, new
| Modifier | Effect |
|---|---|
virtual | The method may be overridden in derived classes. |
override | The method overrides a base virtual or abstract member. |
sealed override | The method overrides and forbids further override. |
abstract | (On a method) has no body; the class must be abstract. |
new | The method hides an inherited member rather than overriding. |
public abstract class Shape {
public abstract double Area(); // must be overridden
public virtual string Name() => "shape"; // may be overridden
}
public class Circle : Shape {
public double Radius { get; init; }
public override double Area() => Math.PI * Radius * Radius;
public sealed override string Name() => "circle";
}
abstract classes cannot be instantiated; they are templates for derivation. sealed classes cannot be derived from; the conventional use is to forbid a class from being extended.
The new modifier hides an inherited member without overriding:
public class Base {
public void Method() => Console.WriteLine("base");
}
public class Derived : Base {
public new void Method() => Console.WriteLine("derived");
}
Base b = new Derived();
b.Method(); // base — hiding does not affect virtual dispatch
((Derived)b).Method(); // derived
The hide is rare and usually a code smell; conventional designs use virtual/override instead.
Interfaces
An interface declares a contract:
public interface IComparable<T> {
int CompareTo(T other);
}
public interface IDrawable {
void Draw();
bool Visible { get; set; }
}
public class Widget : IDrawable {
public bool Visible { get; set; } = true;
public void Draw() { /* ... */ }
}
A class or struct may implement any number of interfaces. The implementation is checked: every member declared in the interface must be implemented (or declared abstract).
Default interface members
C# 8 introduced default interface methods: implementations attached to interface members:
public interface ILogger {
void Log(string message);
void LogError(string message) { // default implementation
Log($"ERROR: {message}");
}
}
public class ConsoleLogger : ILogger {
public void Log(string message) => Console.WriteLine(message);
// LogError uses the default
}
The mechanism admits adding new methods to an interface without breaking existing implementations. The conventional uses are: extending a published interface; providing a “trait”-like reusable behaviour; supplying convenience overloads.
The default methods can be invoked only through the interface type, not directly through the instance:
ConsoleLogger c = new ConsoleLogger();
c.LogError("oops"); // ERROR: not directly accessible
((ILogger)c).LogError("oops"); // OK
Structs
A struct declares a value type:
public struct Point {
public double X { get; init; }
public double Y { get; init; }
public Point(double x, double y) { X = x; Y = y; }
public double DistanceTo(Point other) {
var dx = X - other.X;
var dy = Y - other.Y;
return Math.Sqrt(dx * dx + dy * dy);
}
}
Structs are value types; the full implications are in Reference and value types. The principal C#-specific points:
- Structs cannot inherit from other structs or classes (they implicitly inherit from
System.ValueType, which inherits fromobject). - Structs may implement interfaces.
- The default constructor (parameterless) of a struct is implicitly defined and zero-initialises all fields. C# 10 admitted user-defined parameterless constructors on structs.
readonly struct
A readonly struct forbids mutation:
public readonly struct Point {
public double X { get; }
public double Y { get; }
public Point(double x, double y) { X = x; Y = y; }
}
All fields are implicitly readonly; methods may not modify the struct. The compiler may produce more efficient code for readonly struct because it knows defensive copies are unnecessary.
ref struct
A ref struct is a stack-only value type:
public ref struct LineEnumerator {
private ReadOnlySpan<char> source;
// ...
}
The restrictions: no boxing, no fields of non-ref types, no capture by lambdas or async methods. The principal use is Span<T> and similar low-level types.
Records
A record is a concise type for value-oriented data with structural equality:
public record Point(double X, double Y);
var p1 = new Point(3, 4);
var p2 = new Point(3, 4);
bool eq = p1 == p2; // true: structural equality
Console.WriteLine(p1); // Point { X = 3, Y = 4 }
var p3 = p1 with { Y = 0 }; // Point { X = 3, Y = 0 }; non-destructive
var (x, y) = p1; // deconstruction: x = 3, y = 4
The record declaration synthesises:
- A primary constructor.
- Properties for each parameter (init-only by default).
Equals,GetHashCode, and==/!=implementing structural equality.- A
ToStringshowing the property names and values. - A
Deconstructmethod admitting tuple destructuring. - A clone method underlying the
withexpression.
record class vs record struct
C# 9’s record (or record class) is a reference type. C# 10’s record struct is a value type:
public record class DocumentRef(string Title, string Body); // reference type
public record struct PointVal(double X, double Y); // value type
The conventional choice:
record classfor entities with identity-flavoured data (a domain object passed by reference).record structfor small value-like data (a coordinate, an interval).
Custom members
Records admit additional members beyond the primary-constructor parameters:
public record Person(string FirstName, string LastName) {
public string FullName => $"{FirstName} {LastName}";
public bool Matches(string query) =>
FirstName.Contains(query, StringComparison.OrdinalIgnoreCase) ||
LastName.Contains(query, StringComparison.OrdinalIgnoreCase);
}
The body declares conventional members; the record’s structural equality is based only on the primary-constructor parameters, not on these additions.
Record inheritance
Records may inherit:
public record Animal(string Name);
public record Dog(string Name, string Breed) : Animal(Name);
The structural equality follows the inheritance: a Dog and an Animal with the same Name are not equal because their runtime types differ.
Static classes and members
A static class cannot be instantiated and may contain only static members:
public static class MathUtils {
public const double Pi = 3.14159;
public static double Square(double x) => x * x;
}
double n = MathUtils.Square(5);
The conventional uses are: utility namespaces (MathUtils, StringUtils), extension method classes, helper grouping.
A static member (in any class) belongs to the type, not to instances:
public class Counter {
public static int InstanceCount { get; private set; } = 0;
public Counter() { InstanceCount++; }
}
Counter.InstanceCount is a single value shared across all Counter instances.
Partial classes and partial methods
A class may be split across multiple files with partial:
// File 1
public partial class Document {
public string Title { get; init; } = "";
}
// File 2
public partial class Document {
public void Save() { /* ... */ }
}
The conventional use is generated code: a code generator produces one file, and the user adds members in another file. The compiler combines the parts into a single class.
Partial methods (since C# 3) admit declaration-only signatures that may or may not be implemented:
public partial class Notifier {
partial void OnChange(); // declaration
public void Update() {
// ...
OnChange(); // calls the implementation if present, otherwise no-op
}
}
public partial class Notifier {
partial void OnChange() {
Console.WriteLine("changed");
}
}
C# 9 extended partial methods: a partial method with an explicit access modifier (or a non-void return type) requires an implementation; without one, the method is implicitly private void and the implementation is optional.
Access modifiers
Seven access levels for members (covered in Namespaces):
| Modifier | Visibility |
|---|---|
public | Any code |
internal | Same assembly |
protected | Declaring type and derived types |
private | Declaring type only |
protected internal | Same assembly OR derived types |
private protected | Same assembly AND derived types |
file | Declaring file (C# 11) |
The default for class members is private.
Nested types
A type may be declared inside another type:
public class Container {
public class Iterator {
// nested
}
}
Container.Iterator it = new Container.Iterator();
Nested types live in the enclosing type’s scope; their access is governed by their own modifier. The conventional use is for tightly-coupled types (an iterator, an internal helper).
A note on inheritance versus composition
The conventional advice: prefer composition over inheritance. C# admits inheritance freely, but the brittleness of deep hierarchies — base-class changes ripple through derived classes; the Liskov substitution principle imposes a discipline that is easy to violate; multi-level virtual dispatch is harder to debug than direct calls — makes it a tool of last resort.
When inheritance is appropriate:
- Genuine is-a relationships (Square is-a Shape; Dog is-a Animal).
- Open-set polymorphism (the framework provides a base class; users derive concrete types).
- Frameworks that need run-time polymorphism (UI controls; ORM entities).
When composition is appropriate:
- Code reuse without is-a (a Logger that several classes use).
- Configurable behaviour (strategy pattern; dependency injection).
- Avoiding the rigidity of inheritance.
The patterns interact: the strategy pattern uses composition to inject behaviour; the decorator pattern uses composition to extend behaviour; the visitor pattern uses inheritance to dispatch operations. Modern C# prefers composition for most application code and reserves inheritance for framework boundaries.