Introduction
In Part 1, we put our Barista through the wringer with method overloading and params. In Part 2, MenuItem and SpecialMenuItem taught us everything about inheritance — the base keyword, the one-way street rule, and why you can't equate an Invoice to a Receipt just because they look alike.
This time, we're picking up exactly where Part 2 left off, with the same MenuItem and SpecialMenuItem classes, and pushing them into two topics that genuinely go hand in hand: run-time polymorphism (dynamic binding) and abstract classes. Fair warning — this is a meaty one, easily the longest in the series so far, because these two topics are joined at the hip. Abstract classes only really make sense once run-time polymorphism has clicked for you, so we're covering them back to back, in one sitting.
Grab a coffee (you know the drill by now), and let's get into it.
Here's the series roadmap:
Diving in OOP (Polymorphism and Inheritance – Part 1)
Diving in OOP (Polymorphism and Inheritance – Part 2)
Diving in OOP (Polymorphism and Inheritance – Part 3) — you're here
Diving in OOP (Access Modifiers)
Diving in OOP (Properties)
Note: Every code snippet here has actually been run — no theoretical guessing, just real compiler output (including the errors, especially the errors).
Series Articles
Part A: Run-Time Polymorphism (Dynamic Binding)
Picking Up Where We Left Off
Remember this pair from Part 2?
MenuItem.cs:
public class MenuItem
{
public void ShowPrice()
{
Console.WriteLine("MenuItem ShowPrice");
}
}
SpecialMenuItem.cs:
public class SpecialMenuItem : MenuItem
{
public void ShowPrice()
{
Console.WriteLine("SpecialMenuItem ShowPrice");
}
}
We already know what happens here — SpecialMenuItem's ShowPrice() hides MenuItem's, and C# throws that familiar warning: 'SpecialMenuItem.ShowPrice()' hides inherited member 'MenuItem.ShowPrice()'. Use the new keyword if hiding was intended.
Here's the thing we didn't dig into back then — which version actually gets called depends entirely on the type of the reference you're holding, not the object underneath it. Let's prove it.
public class Program
{
private static void Main(string[] args)
{
SpecialMenuItem x = new SpecialMenuItem();
MenuItem y = new MenuItem();
MenuItem z = new SpecialMenuItem();
x.ShowPrice();
y.ShowPrice();
z.ShowPrice();
}
}
Output:
MenuItem ShowPrice
MenuItem ShowPrice
MenuItem ShowPrice
Wait — that's not a typo. All three calls print MenuItem ShowPrice. Even x, which is clearly holding a SpecialMenuItem object, still calls MenuItem's version.
Point to Remember: In C#, a smaller (derived) object can always be assigned to a bigger (base class) reference. That's exactly what z is doing here — a SpecialMenuItem object, sitting inside a MenuItem-typed box.
But why did even x, declared as SpecialMenuItem, print MenuItem ShowPrice? It shouldn't have — let that mystery sit for one second while we introduce the two words that fix everything: virtual and override.
Turning ShowPrice Into a Real Override
Let's mark ShowPrice as virtual in MenuItem, and properly override it in SpecialMenuItem. While we're at it, let's add two more methods — ApplyDiscount and ShowDescription — so we can watch three different scenarios play out side by side.
MenuItem.cs:
public class MenuItem
{
public virtual void ShowPrice()
{
Console.WriteLine("MenuItem ShowPrice");
}
public virtual void ApplyDiscount()
{
Console.WriteLine("MenuItem ApplyDiscount");
}
public virtual void ShowDescription()
{
Console.WriteLine("MenuItem ShowDescription");
}
}
SpecialMenuItem.cs:
public class SpecialMenuItem : MenuItem
{
public override void ShowPrice()
{
Console.WriteLine("SpecialMenuItem ShowPrice");
}
public new void ApplyDiscount()
{
Console.WriteLine("SpecialMenuItem ApplyDiscount");
}
public void ShowDescription()
{
Console.WriteLine("SpecialMenuItem ShowDescription");
}
}
Program.cs:
SpecialMenuItem x = new SpecialMenuItem();
MenuItem y = new MenuItem();
MenuItem z = new SpecialMenuItem();
x.ShowPrice(); x.ApplyDiscount(); x.ShowDescription();
y.ShowPrice(); y.ApplyDiscount(); y.ShowDescription();
z.ShowPrice(); z.ApplyDiscount(); z.ShowDescription();
Output:
SpecialMenuItem ShowPrice
SpecialMenuItem ApplyDiscount
SpecialMenuItem ShowDescription
MenuItem ShowPrice
MenuItem ApplyDiscount
MenuItem ShowDescription
SpecialMenuItem ShowPrice
MenuItem ApplyDiscount
MenuItem ShowDescription
Look closely at z — same MenuItem-typed reference as before, same SpecialMenuItem object underneath, but this time only ShowPrice() actually calls SpecialMenuItem's version. Let's break down why, method by method:
z.ShowPrice() → ShowPrice is virtual in MenuItem, and override in SpecialMenuItem. override tells C#: "forget what type z is declared as — look at the real object sitting inside it. It's a SpecialMenuItem, so run SpecialMenuItem's version." → SpecialMenuItem ShowPrice.
z.ApplyDiscount() → ApplyDiscount is marked new in SpecialMenuItem. new is C#'s way of saying "this method has nothing to do with the one in the base class — they just happen to share a name, like two different coffee shops both calling their loyalty program 'Gold Membership'." Since z is declared as MenuItem, and the SpecialMenuItem version cut the connection, C# calls MenuItem's version. → MenuItem ApplyDiscount.
z.ShowDescription() → No modifier at all on ShowDescription in SpecialMenuItem. Skipping the modifier is treated as new by default. Same outcome as ApplyDiscount. → MenuItem ShowDescription.
Point to Remember: The override modifier gives the derived class method first priority, but only when the reference's declared type also has a virtual (or abstract) version to hook into.
Point to Remember: new and override only mean anything on top of a virtual (or abstract) base method. Skip the modifier entirely, and C# quietly assumes new.
Three Menu Items Deep
Let's add a third tier to our menu hierarchy — a SeasonalMenuItem that derives from SpecialMenuItem.
public class MenuItem
{
public void ShowPrice()
{
Console.WriteLine("MenuItem ShowPrice");
}
public virtual void ApplyDiscount()
{
Console.WriteLine("MenuItem ApplyDiscount");
}
public virtual void ShowDescription()
{
Console.WriteLine("MenuItem ShowDescription");
}
}
public class SpecialMenuItem : MenuItem
{
public virtual void ShowPrice()
{
Console.WriteLine("SpecialMenuItem ShowPrice");
}
public new void ApplyDiscount()
{
Console.WriteLine("SpecialMenuItem ApplyDiscount");
}
public override void ShowDescription()
{
Console.WriteLine("SpecialMenuItem ShowDescription");
}
}
public class SeasonalMenuItem : SpecialMenuItem
{
public override void ShowPrice()
{
Console.WriteLine("SeasonalMenuItem ShowPrice");
}
public void ShowDescription()
{
Console.WriteLine("SeasonalMenuItem ShowDescription");
}
}
MenuItem y = new SpecialMenuItem();
MenuItem x = new SeasonalMenuItem();
SpecialMenuItem z = new SeasonalMenuItem();
y.ShowPrice(); y.ApplyDiscount(); y.ShowDescription();
x.ShowPrice(); x.ApplyDiscount(); x.ShowDescription();
z.ShowPrice(); z.ApplyDiscount(); z.ShowDescription();
Output:
MenuItem ShowPrice
MenuItem ApplyDiscount
SpecialMenuItem ShowDescription
MenuItem ShowPrice
MenuItem ApplyDiscount
SpecialMenuItem ShowDescription
SeasonalMenuItem ShowPrice
MenuItem ApplyDiscount
SpecialMenuItem ShowDescription
Notice how y and x behave identically, even though x is really holding a SeasonalMenuItem two levels down. That's because ShowPrice is non-virtual in MenuItem — the chain never even gets a chance to start, so C# stops right there at compile time, no matter what's really underneath. Only z, declared as SpecialMenuItem where ShowPrice is virtual, lets the chain continue down into SeasonalMenuItem.
Point to Remember: If the base reference's declared type doesn't mark a method virtual, the chain never starts — the method resolves at compile time based purely on the declared type, full stop.
Point to Remember: For virtual methods, resolution happens at run time based on the object's real type. For non-virtual methods, resolution happens at compile time based on the reference's declared type.
Cutting Off Family Ties
Here's a scenario that trips up even experienced developers, so slow down and read this one twice.
internal class Beverage
{
public virtual void Describe()
{
Console.WriteLine("Beverage: generic drink");
}
}
internal class HotBeverage : Beverage
{
public new virtual void Describe()
{
Console.WriteLine("HotBeverage: served warm");
}
}
internal class SignatureHotBeverage : HotBeverage
{
public override void Describe()
{
Console.WriteLine("SignatureHotBeverage: our own blend");
}
}
Beverage a = new SignatureHotBeverage();
a.Describe();
HotBeverage b = new SignatureHotBeverage();
b.Describe();
Output:
Beverage: generic drink
SignatureHotBeverage: our own blend
a is declared as Beverage, and Describe is virtual there. But before C# proceeds down to SignatureHotBeverage, it checks HotBeverage first — and finds that HotBeverage's Describe is marked new. That single word severs the link with Beverage's Describe entirely. The chain from Beverage dead-ends right there, so a.Describe() falls back to Beverage's own version.
b, on the other hand, is declared as HotBeverage, where Describe is both new and virtual — a fresh, completely independent virtual chain starts right there. SignatureHotBeverage's override plugs into that new chain, so b.Describe() correctly reaches SignatureHotBeverage.
Now remove override from SignatureHotBeverage, and it silently defaults back to new:
Output:
Beverage: generic drink
HotBeverage: served warm
Point to Remember: Combining new with virtual starts a brand-new, independent virtual chain — completely disconnected from anything declared above it.
Four Menu Items, One Confusing Virtual Chain
Let's push this to its logical extreme with a full four-tier menu hierarchy: MenuItem → SpecialMenuItem → SeasonalMenuItem → LimitedEditionMenuItem.
public class MenuItem
{
public virtual void ShowPrice()
{
Console.WriteLine("MenuItem ShowPrice");
}
}
public class SpecialMenuItem : MenuItem
{
public override void ShowPrice()
{
Console.WriteLine("SpecialMenuItem ShowPrice");
}
}
public class SeasonalMenuItem : SpecialMenuItem
{
public virtual new void ShowPrice()
{
Console.WriteLine("SeasonalMenuItem ShowPrice");
}
}
public class LimitedEditionMenuItem : SeasonalMenuItem
{
public override void ShowPrice()
{
Console.WriteLine("LimitedEditionMenuItem ShowPrice");
}
}
MenuItem a = new LimitedEditionMenuItem();
SpecialMenuItem b = new LimitedEditionMenuItem();
SeasonalMenuItem c = new LimitedEditionMenuItem();
LimitedEditionMenuItem d = new LimitedEditionMenuItem();
a.ShowPrice(); b.ShowPrice(); c.ShowPrice(); d.ShowPrice();
Output:
SpecialMenuItem ShowPrice
SpecialMenuItem ShowPrice
LimitedEditionMenuItem ShowPrice
LimitedEditionMenuItem ShowPrice
For a and b, the virtual chain that starts at MenuItem and continues through SpecialMenuItem's override gets cut off at SeasonalMenuItem, because SeasonalMenuItem declared ShowPrice as new. The chain from MenuItem never reaches LimitedEditionMenuItem, so both fall back to SpecialMenuItem. For c and d, we're inside SeasonalMenuItem's own fresh chain, and LimitedEditionMenuItem's override plugs straight into that one.
Point to Remember: override can't be combined with new, static, or virtual — but it can be combined with abstract, which we'll get to shortly.
Reaching the Parent With base — Now With Virtual Methods
We already met base back in Part 2, calling a plain method on the base class. It works exactly the same way with virtual methods, always calling the immediate parent's version, non-virtually, no matter what.
public class MenuItem
{
public virtual void ShowPrice()
{
Console.WriteLine("MenuItem ShowPrice");
}
}
public class SpecialMenuItem : MenuItem
{
public override void ShowPrice()
{
base.ShowPrice();
Console.WriteLine("SpecialMenuItem ShowPrice");
}
}
MenuItem item = new SpecialMenuItem();
item.ShowPrice();
Output:
MenuItem ShowPrice
SpecialMenuItem ShowPrice
Simple, predictable, and exactly what you'd expect after Part 2 — base.ShowPrice() always walks straight up to the immediate parent, virtual or not.
The Infinite Loop Trap
One more gotcha before we move on, and it's a genuinely nasty one to debug if you hit it cold.
public class MenuItem
{
public virtual void ShowPrice()
{
Console.WriteLine("MenuItem ShowPrice");
}
}
public class SpecialMenuItem : MenuItem
{
public override void ShowPrice()
{
((MenuItem)this).ShowPrice();
Console.WriteLine("SpecialMenuItem ShowPrice");
}
}
MenuItem item = new SpecialMenuItem();
item.ShowPrice();
Output:
Error: Cannot evaluate expression because the current thread is in a stack overflow state.
Casting this to MenuItem doesn't help at all — the object's actual runtime type is still SpecialMenuItem, and virtual dispatch always looks at the runtime type, cast or no cast. So ((MenuItem)this).ShowPrice() still calls SpecialMenuItem.ShowPrice(), which calls itself again, and again, until the stack finally taps out.
Point to Remember: Casting a reference never changes which overridden method actually gets called for a virtual method. Use base.ShowPrice(), never a cast, when you genuinely want the parent's implementation.
Summary - Run-Time Polymorphism
Before we move to abstract classes, let's lock in what we've covered:
A smaller (derived) object can always be assigned to a bigger (base) reference type.
override gives the derived method first priority, but only when the base reference's declared type has a virtual (or abstract) version to override.
new and override only mean something on top of a virtual (or abstract) base method — skip the modifier, and C# assumes new.
Virtual methods resolve at run time based on the object's actual type. Non-virtual methods resolve at compile time based on the reference's declared type.
override cannot combine with new, static, or virtual — but it can combine with abstract.
Casting doesn't change which overridden method gets called for a virtual method.
Part B: Abstract Classes
You Can't Just Order "a Beverage"
Walk up to any coffee counter and say "I'll have a beverage, please" — the barista is going to stare at you, blink, and ask what you actually mean. Latte? Cappuccino? Cold brew? "Beverage" isn't a real order — it's a category, a concept, something that only becomes real once you pick a specific drink.
That's exactly what an abstract class is. It defines a shared shape — the idea of "a beverage" — but it deliberately leaves the important part incomplete, and refuses to let you create one directly. Only its concrete children, the actual Lattes and Cappuccinos of the world, can be ordered.
Let's prove it. Create a class called Beverage, and mark it abstract.
public abstract class Beverage
{
}
Program.cs:
public class Program
{
private static void Main(string[] args)
{
Beverage drink = new Beverage();
}
}
Output:
Compile time error: Cannot create an instance of the abstract class or interface 'Beverage'
Exactly as expected — you can't order "a Beverage." C# won't even let you try.
Point to Remember: You cannot create an object of an abstract class using new — no exceptions, no workarounds.
Abstract Doesn't Mean Empty
An abstract class isn't necessarily hollow inside — it can hold fully working, ordinary methods too.
public abstract class Beverage
{
public int basePrice;
public void ShowShopInfo()
{
Console.WriteLine("Welcome to The Byte-Sized Café");
}
}
Try new Beverage() again, and you'll get the exact same error as before. Having a perfectly functional ShowShopInfo() method doesn't lift the ban — the abstract modifier on the class itself is what matters, regardless of what's inside.
Concrete Drinks Can Still Be Ordered
Now let's give Beverage a child.
public abstract class Beverage
{
public int basePrice;
public void ShowShopInfo()
{
Console.WriteLine("Welcome to The Byte-Sized Café");
}
}
public class Latte : Beverage
{
}
var order = new Latte();
order.ShowShopInfo();
Output:
Welcome to The Byte-Sized Café
No error at all. Latte is a genuine, specific drink — the barista knows exactly what to make.
Point to Remember: A class can derive from an abstract class, and that derived class can absolutely be instantiated.
Declaring the Part That's Missing
Here's where abstract classes earn their name. Let's declare a method that has no body at all.
public abstract class Beverage
{
public int basePrice;
public void ShowShopInfo() { }
public void Prepare();
}
Output:
Compile time error: 'Beverage.Prepare()' must declare a body because it is not marked abstract, extern, or partial
A method without a body needs to explicitly say so, using the abstract keyword.
public abstract void Prepare();
Now compile Latte : Beverage without implementing Prepare:
Output:
Compile time error: 'Latte' does not implement inherited abstract member 'Beverage.Prepare()'
Point to Remember: Once a method is declared abstract in the base class, implementing it becomes the derived class's responsibility. Until it does, you cannot create an object of that derived class — no matter how many other methods it already has working fine.
Implementing the Missing Recipe
public class Latte : Beverage
{
public void Prepare()
{
Console.WriteLine("Steaming milk, pulling shots");
}
}
Compile this, and you'll actually get two problems, not one,
Output:
Compile time error: 'Latte' does not implement inherited abstract member 'Beverage.Prepare()'
Compile time warning: 'Latte.Prepare()' hides inherited member 'Beverage.Prepare()'. Add override, or add new.
Exactly the same rule from Part A applies here — an abstract method needs override explicitly on the implementing method, same as any virtual one.
public class Latte : Beverage
{
public override void Prepare()
{
Console.WriteLine("Steaming milk, pulling shots");
}
}
Clean compile. Order up.
The Recipe Signature Can't Change
Try tweaking the return type in Latte,
public class Latte : Beverage
{
public override int Prepare()
{
return 0;
}
}
Output:
Compile time error: 'Latte.Prepare()': return type must be 'void' to match overridden member 'Beverage.Prepare()'
Point to Remember: When overriding an abstract method, the derived class cannot change the return type or parameters — no matter how many abstract methods the abstract class declares.
Add a few more abstract methods to Beverage — say, abstract void AddMilk(); and abstract void AddSugar(); — and forget to implement any of them in Latte, and C# will happily list out every single missing one as a separate compiler error, not just the first.
Point to Remember: An abstract class is inherently incomplete — it exists purely to serve as a base for other classes, never to be used directly.
Fields Work Exactly Like Normal
One small reassurance — basePrice inside Beverage behaves exactly like a field in any regular class. Leave it uninitialized, and it quietly defaults to 0, same as always. Abstract only restricts instantiation and method completeness — nothing about the ordinary fields sitting alongside them.
One Abstract Method Means the Whole Class Must Be Abstract
public class Beverage // note: not marked abstract
{
public int basePrice;
public void ShowShopInfo() { }
public abstract void Prepare();
}
Output:
Compile time error: 'Beverage.Prepare()' is abstract but it is contained in non-abstract class 'Beverage'
Point to Remember: If even a single method in a class is marked abstract, the entire class must be declared abstract too.
Point to Remember: An abstract method cannot use the static or virtual modifiers.
You Can't Call an Abstract Recipe
public class Latte : Beverage
{
public override void Prepare()
{
base.Prepare();
}
}
Output:
Compile time error: Cannot call an abstract base member: 'Beverage.Prepare()'
Fair enough — there's no actual recipe behind Beverage.Prepare(). C# won't let you call something that was never written.
When the Middle Class Is Abstract Too
Things get genuinely interesting when an abstract class sits in the middle of a chain — derived from something concrete, but base to something else. Consider,
public class Drink
{
public virtual void Serve()
{
Console.WriteLine("Drink: served as-is");
}
}
public abstract class HotDrink : Drink
{
public new abstract void Serve();
}
public class Cappuccino : HotDrink
{
public override void Serve()
{
Console.WriteLine("Cappuccino: served with foam art");
}
}
Drink a = new Cappuccino();
HotDrink b = new Cappuccino();
a.Serve();
b.Serve();
Output:
Drink: served as-is
Cappuccino: served with foam art
Drink here is a perfectly normal, non-abstract class with a virtual Serve. HotDrink, itself abstract, marks Serve as new abstract — deliberately cutting the link to Drink's Serve (the new part) and leaving it unimplemented (the abstract part), forcing Cappuccino to build it from scratch.
a.Serve(), declared as Drink, checks Drink first, finds Serve marked virtual, and since HotDrink cut the connection with new, the chain halts right there. Result: Drink: served as-is.
b.Serve(), declared as HotDrink, sits on the brand-new chain HotDrink started, and Cappuccino's override plugs straight into it. Result: Cappuccino: served with foam art.
Swap override for new in Cappuccino, and you'll get,
Output:
Compile time error: 'Cappuccino' does not implement inherited abstract member 'HotDrink.Serve()'
Because HotDrink.Serve() is abstract, Cappuccino has no choice but to override it — new simply doesn't satisfy an abstract contract.
Point to Remember: Virtual methods run marginally slower than non-virtual ones, since the method to call is resolved at run time rather than compile time.
Can an Abstract Class Be Sealed?
Short answer — no, and it's not a style guideline, it simply won't compile.
public sealed abstract class Beverage
{
public abstract void Prepare();
}
Output:
Compile time error: 'Beverage': an abstract class cannot be sealed or static
Think about what the two words actually mean. sealed says "nobody is allowed to inherit from this." abstract says "somebody must inherit from this before it's ever useful." Put them together and you've built a class that can never be created and can never be extended — a beverage nobody can ever order and nobody can ever define. C# shuts that down immediately.
Point to Remember: An abstract class cannot be sealed.
Point to Remember: An abstract class cannot be static either, for the same underlying reason — static also rules out both instantiation and inheritance.
Conclusion
That's run-time polymorphism and abstract classes, back to back, in one sitting — exactly as long as promised at the start. The two topics genuinely reinforce each other: an abstract class is really just virtual/override pushed to its logical extreme, where the base class refuses to provide any implementation at all, and hands the entire job over to whoever inherits from it.
If you take away just one idea from this whole article, make it this one — virtual dispatch always looks at the actual object underneath, never the declared type of the reference, and new always severs that connection. Everything else here is just a variation on that single rule.
Here's the full round-up, one more time:
A derived object can always be assigned to a base-typed reference.
override only wins when the base reference's declared type has a virtual (or abstract) version to hook into.
new and override are meaningless without a virtual (or abstract) base method — skip the modifier, and C# assumes new.
Virtual methods resolve at run time based on the real object type; non-virtual methods resolve at compile time based on the declared reference type.
override can't combine with new, static, or virtual, but it can combine with abstract.
Casting a reference never changes which overridden method actually runs.
You cannot create an object of an abstract class using new.
A class derived from an abstract class can be instantiated normally, once every abstract member is implemented.
Overriding an abstract method can't change its return type or parameters.
A single abstract method forces the entire containing class to be abstract too.
An abstract method can't be static or virtual, and an abstract class can't be sealed or static.
Next up in the series: Access Modifiers — who gets to see what, and why public, private, protected, internal, protected internal, and private protected each draw that line in a different place. See you there. Keep coding, keep learning, and maybe grab a coffee while you're at it. ☕