Engineering Notes
Home/Languages/C#/C# OOP Fundamentals
Languages · C#

C# OOP Fundamentals

Properties are C#'s idiomatic replacement for manual getter/setter method pairs (getBalance()/setBalance()), but they compile down to exactly that — a property is syntactic sugar over a pair of hidden methods named get_Balance and set_Balance (see "Under the Hood"). { get; } with no setter creates a property only…

💻 Languages● Beginner⏱ 9 min read🛠️ 3 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • Classes, Fields, Properties, and Constructors
  • Auto-Properties and init
  • Inheritance and virtual/override/sealed
  • Abstract Classes vs Interfaces
  • Polymorphism in Practice

1Classes, Fields, Properties, and Constructors

csharp
class BankAccount
{
    private decimal _balance;                 // field — backing storage
    public string Owner { get; }               // read-only auto-property
    public decimal Balance => _balance;          // expression-bodied property

    public BankAccount(string owner, decimal openingBalance)
    {
        Owner = owner;
        _balance = openingBalance;
    }

    public void Deposit(decimal amount)
    {
        if (amount <= 0) throw new ArgumentOutOfRangeException(nameof(amount));
        _balance += amount;
    }
}

Properties are C#'s idiomatic replacement for manual getter/setter method pairs (getBalance()/setBalance()), but they compile down to exactly that — a property is syntactic sugar over a pair of hidden methods named get_Balance and set_Balance (see "Under the Hood"). { get; } with no setter creates a property only assignable inside the constructor (or with an initializer).

2Auto-Properties and init

csharp
class Person
{
    public string Name { get; set; }             // full auto-property, mutable
    public string Id { get; init; }                // settable only at construction time (C# 9+)
}

var p = new Person { Name = "Ana", Id = "abc123" };  // object initializer syntax
p.Name = "Ana2";       // fine
p.Id = "xyz";           // compile error — init-only, already constructed

init accessors let you build genuinely immutable-after-construction objects while still supporting the convenient object-initializer syntax (new Person { ... }), which a plain constructor-only approach can't offer for optional/named properties.

3Inheritance and virtual/override/sealed

csharp
class Shape
{
    public virtual double Area() => 0;
    public virtual string Describe() => $"A shape with area {Area()}";
}

class Circle : Shape
{
    public double Radius { get; init; }
    public override double Area() => Math.PI * Radius * Radius;
}

class Square : Shape
{
    public double Side { get; init; }
    public sealed override double Area() => Side * Side;  // sealed: no further overriding down the chain
}

A method is only overridable if the base class explicitly marks it virtual (or abstract) — unlike Java, where every instance method is virtual by default. This is a deliberate C# design choice: virtual dispatch has a real cost (an indirect call through the method table instead of a direct call — see "Under the Hood"), and requiring virtual opt-in means the JIT can devirtualize (inline as a direct call) any method the author never intended to be overridden.

4Abstract Classes vs Interfaces

csharp
abstract class Animal
{
    public abstract string MakeSound();          // no body — MUST be overridden
    public void Sleep() => Console.WriteLine("zzz"); // shared, concrete implementation
}

interface IFlyable
{
    void Fly();
    void Land() { Console.WriteLine("landed"); }   // default interface method (C# 8+)
}

class Bird : Animal, IFlyable
{
    public override string MakeSound() => "tweet";
    public void Fly() => Console.WriteLine("flying");
}
Abstract classInterface
Instance fieldsyesno (only static)
Constructorsyesno
Multiple inheritanceno — single base class onlyyes — a class can implement many interfaces
Default method bodiesyes, always allowedyes, since C# 8 (default interface methods)
Access modifiers on membersyes (protected, private, ...)members public by default

Use an abstract class when subtypes share actual state and implementation ("is-a" with common guts); use an interface for a capability contract that unrelated types can satisfy ("can-do").

5Polymorphism in Practice

csharp
Shape[] shapes = { new Circle { Radius = 2 }, new Square { Side = 3 } };
foreach (var s in shapes)
    Console.WriteLine(s.Describe());   // calls the RIGHT Area() override for each concrete type at runtime

Describe() is defined once on Shape but calls Area() polymorphically — even though Describe is inherited unchanged, the Area() call inside it resolves to whichever override matches the object's actual runtime type, not its compile-time (Shape) type. This is virtual dispatch.

6Access Modifiers

csharp
public class Widget       // visible everywhere
{
    private int _id;         // this class only
    protected int Count;       // this class + derived classes
    internal string Tag;         // this assembly only
    protected internal int X;      // this assembly, OR derived classes in any assembly
    private protected int Y;         // this assembly AND derived classes (intersection, C# 7.2+)
}

internal (assembly-scoped visibility) has no equivalent in Java or TypeScript — it's C#'s answer to "public within our library, invisible outside it," commonly used to expose implementation details to same-assembly test projects (via [InternalsVisibleTo]) without making them part of the public API surface.

7Records (C# 9+): Value-Based Equality for Reference Types

csharp
record Point3D(double X, double Y, double Z);

var a = new Point3D(1, 2, 3);
var b = new Point3D(1, 2, 3);
Console.WriteLine(a == b);        // True — records compare BY VALUE, not by reference
Console.WriteLine(ReferenceEquals(a, b)); // False — still two distinct heap objects

var c = a with { Z = 99 };         // non-destructive mutation — copies a, changes Z

A record is still a reference type (heap-allocated, unless declared record struct), but the compiler auto-generates value-based Equals, GetHashCode, ToString, and the with non-destructive-copy expression — eliminating a large amount of boilerplate that Java's plain classes and older C# required by hand for immutable data-carrying types.

Under the HoodMethod Tables, Virtual Dispatch, and Object Layout▾

How the CLR represents an object

Every reference-type instance on the managed heap has a small, fixed header before its field data: a method table pointer (also called the type handle) and a sync block index (used for lock, GetHashCode on object, and other runtime bookkeeping). The method table pointer is what makes GetType(), virtual dispatch, and type checks (is/as) work — it points to a single, shared-per-type structure (the MethodTable) that the JIT and runtime consult constantly.

text
[ object on heap ]
  ┌─────────────────────────┐
  │ Sync block index          │  ← used by lock(), etc.
  │ MethodTable pointer         │  ← shared across ALL instances of this type
  ├─────────────────────────┤
  │ field 1                     │
  │ field 2                     │
  │ ...                         │
  └─────────────────────────┘
Circle instance's method table pointer leads to a vtable whose Area slot points at Circle.Area, overwriting the inherited Shape.Area slot
Circle instance's method table pointer leads to a vtable whose Area slot points at Circle.Area, overwriting the inherited Shape.Area slot

What the MethodTable contains

The MethodTable holds, among other things, a virtual method table (vtable) — a flat array of method-pointer slots, one per virtual method the type declares or inherits, indexed by a fixed slot number assigned at class-load time. A derived class that overrides a virtual method doesn't get a new slot; it OVERWRITES the inherited slot in its own copy of the vtable with a pointer to its own implementation. This is exactly how shapes[i].Area() in section 5 resolves to the right code: the call site doesn't know at compile time which Area to call, so the JIT emits an indirect call — load the object's MethodTable pointer, index into its vtable at Area's fixed slot, jump through that pointer.

text
Shape's vtable slot 3: Area  →  Shape.Area (default)
Circle's vtable slot 3: Area →  Circle.Area (override)
Square's vtable slot 3: Area →  Square.Area (override)

Non-virtual calls skip all of this

A non-virtual method call is resolved entirely at compile time to a direct call to a fixed address — no indirection, no vtable lookup, and critically, it's a candidate for JIT inlining, which a virtual call generally is not (the JIT can't know which override will run without additional analysis called devirtualization, which it does perform in some cases — e.g. when the concrete type is provably known, or the class/method is sealed). This is the real, measurable cost behind "prefer non-virtual unless you actually need polymorphism," and it's why sealed on a class or override is more than documentation — it's a signal the JIT can use to devirtualize confidently.

Properties are just methods in disguise, at the IL level

csharp
public int Count { get; set; }

compiles to a private backing field (something like <Count>k__BackingField) plus two ordinary methods, get_Count() and set_Count(int value), each marked with a specialname flag so tooling knows to present them as a property. Count at a call site is literally a call to get_Count(). This is why properties can be virtual, can have different access modifiers on getter vs setter, and — crucially for interop — why C# properties and Java-style getter/setter methods produce nearly identical IL/bytecode shapes despite the very different surface syntax.

Interfaces have no storage, only a dispatch table entry

A class implementing an interface gets an interface map in its MethodTable — effectively a separate small vtable per implemented interface, mapping the interface's methods to the class's actual implementations. Calling through an interface reference (IFlyable f = bird; f.Fly();) does an extra level of indirection versus calling through the concrete class reference directly, which is one reason (a very minor one on modern JITs, but a real one) interface dispatch is traditionally considered marginally more expensive than a direct virtual call through the concrete type.

⚠️ Common Pitfalls

  1. Forgetting virtual on a base method and being surprised an override doesn't run — without virtual in the base and override (or the hiding new) in the derived class, calling through a base-typed reference always runs the BASE implementation, never the derived one, even if the derived class defines a method with the identical signature (that's method hiding, not overriding — a frequent, hard-to-spot bug).
  2. Confusing new (member hiding) with override — new breaks polymorphism silently: which method runs depends on the compile-time type of the reference, not the runtime type, the opposite of what override gives you.
  3. Mutable public auto-properties on types meant to be value objects — leads to objects that can be silently mutated anywhere they're referenced; prefer init or private setters plus records for anything conceptually immutable.
  4. Calling a virtual method from a constructor. The object isn't fully constructed yet, and if a derived class overrides that method, the override runs before the derived class's own field initializers/constructor body have executed — a classic source of NullReferenceException on freshly constructed objects.
  5. Treating record equality and ReferenceEquals as the same thing — == on records is value-based by default; two different heap objects with identical field values are == but not ReferenceEquals, which surprises people coming from plain-class background where == defaults to reference equality.
  6. Overusing internal/protected internal as a substitute for real API design — assembly-scoped visibility is powerful but easy to abuse as "public, but I don't want to admit it," leaking implementation details across an assembly boundary informally.

📋 Quick Reference

text
class Foo : Base, IInterface1, IInterface2   // single base class, multiple interfaces

virtual   → base class opts a method INTO overriding
override  → derived class provides a new implementation, same vtable slot
sealed    → forbids further overriding (also enables JIT devirtualization)
abstract  → no body, subclass MUST override; class itself can't be instantiated
new       → hides (does NOT override) — breaks polymorphism, resolved at compile time

{ get; }        → read-only after constructor
{ get; init; }   → read-only after object-initializer-time construction
{ get; set; }     → fully mutable

Access modifiers, narrowest to widest:
private < private protected < protected < internal < protected internal < public

record Point(double X, double Y);   // value-based Equals/GetHashCode/ToString + `with` expressions

🎤 Interview Q&A

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

Q1Class vs struct in C#?core▾

Classes are reference types supporting inheritance; structs are value types, copied on assignment, without inheritance (can implement interfaces), best for small immutable data. Large mutable structs cause copying bugs and costs.

Follow-ups interviewers ask
  • readonly struct? Guarantees immutability and avoids defensive copies.
Q2Abstract class vs interface in C#?core▾

Abstract classes can hold state, constructors and implementations, with single inheritance. Interfaces define contracts, allow multiple implementation, and since C# 8 can have default implementations and static members.

Follow-ups interviewers ask
  • Static abstract members? Enable generic math (C# 11).
Q3What are properties and why use them over public fields?core▾

Properties wrap fields with get/set accessors, enabling validation, computed values, init-only setters, required members, and binary-compatible changes later.

Follow-ups interviewers ask
  • Auto-property? public string Name { get; init; }.
Q4virtual, override, new and sealed?senior▾

virtual allows overriding; override replaces the base implementation (dynamic dispatch); new hides a member (static dispatch by reference type, often confusing); sealed prevents further overriding/inheritance.

Follow-ups interviewers ask
  • Why seal classes? Design intent and small performance wins.
Q5What are extension methods?core▾

Static methods in static classes with this T first parameter, callable like instance methods; LINQ is built on them. C# 14 adds extension members including properties.

Follow-ups interviewers ask
  • Risk? Overuse hides where behaviour lives.

🛠️ Practice exercises

Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.