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…
🎯 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
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
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 constructedinit 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
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
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 class | Interface | |
|---|---|---|
| Instance fields | yes | no (only static) |
| Constructors | yes | no |
| Multiple inheritance | no — single base class only | yes — a class can implement many interfaces |
| Default method bodies | yes, always allowed | yes, since C# 8 (default interface methods) |
| Access modifiers on members | yes (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
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 runtimeDescribe() 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
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
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 ZA 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.
[ object on heap ]
┌─────────────────────────┐
│ Sync block index │ ← used by lock(), etc.
│ MethodTable pointer │ ← shared across ALL instances of this type
├─────────────────────────┤
│ field 1 │
│ field 2 │
│ ... │
└─────────────────────────┘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.
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
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
- Forgetting
virtualon a base method and being surprised an override doesn't run — withoutvirtualin the base andoverride(or the hidingnew) 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). - Confusing
new(member hiding) withoverride—newbreaks polymorphism silently: which method runs depends on the compile-time type of the reference, not the runtime type, the opposite of whatoverridegives you. - Mutable public auto-properties on types meant to be value objects — leads to objects that can be silently mutated anywhere they're referenced; prefer
initor private setters plus records for anything conceptually immutable. - 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
NullReferenceExceptionon freshly constructed objects. - Treating
recordequality andReferenceEqualsas the same thing —==on records is value-based by default; two different heap objects with identical field values are==but notReferenceEquals, which surprises people coming from plain-class background where==defaults to reference equality. - Overusing
internal/protected internalas 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
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
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.
- 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.
- 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.
- 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.
- 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.
- 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.