C# Polymorphism for Beginners: virtual and override
Polymorphism lets you treat objects of different types through a common interface — the foundation of flexible, extensible code.
Imagine you have a drawing application. You want to calculate the area of any shape — circles, rectangles, triangles. You could write a method for each shape, but then every time you add a new shape, you'd have to update the area‑calculation code. That's messy and error‑prone. Instead, you define a common Shape base with an Area() method. Each shape provides its own implementation. Then you can write a single method that takes a Shape and calls Area() — and it works for any shape, past, present, or future. That's polymorphism: the ability to treat objects of different types uniformly, while their specific behavior is determined at runtime.
In this lesson, you'll learn what polymorphism is, how it works in C#, the difference between compile‑time and runtime polymorphism, and how to use it to build flexible, maintainable systems.
What Is It?
The Simple Explanation
Polymorphism (Greek for "many forms") means that a single operation can behave differently on different types of objects. In C#, this usually means you can call a method on a base class (or interface) reference, and the actual implementation is chosen at runtime based on the object's real type.
The Technical Definition
Polymorphism in C# comes in two flavors:
- Compile‑time (static) polymorphism — achieved via method overloading and operator overloading. The method to call is determined at compile time based on the number and types of arguments.
- Runtime (dynamic) polymorphism — achieved via virtual/override members and interface implementations. The method to call is determined at runtime based on the actual type of the object.
Runtime polymorphism is the more powerful and commonly referenced form. It relies on the inheritance hierarchy and the virtual method table (vtable) to dispatch calls.
Why Does It Exist?
The Problem
Without polymorphism, you'd be forced to write separate logic for every type you handle. Adding a new type requires modifying existing code (violating the Open/Closed Principle). Code becomes rigid, repetitive, and difficult to extend. You also cannot store different types in a single collection or pass them to a common method that works with all of them.
The Solution
Polymorphism solves this by:
- Unifying interfaces — you write code against a base type (or interface), and the runtime dispatches to the correct implementation.
- Extensibility — you can add new types without changing existing code (as long as they adhere to the contract).
- Code reuse — you write generic algorithms that work on any subtype.
- Maintainability — changes to one class don't affect others as long as the contract is respected.
Big Picture
Polymorphism is at the heart of object‑oriented programming. Here's how it fits into a typical application:
interface IShape { double Area(); } or abstract class Shape with virtual double Area().
Circle, Rectangle, Triangle each provide their own Area() implementation.
double TotalArea(IEnumerable<IShape> shapes) works for any shape.
Area() is called based on each object's actual type. No conditional logic (if/switch) needed.
How It Works
Step 1 — Define a base with virtual members
public class Animal
{
public virtual void Speak() => Console.WriteLine("Animal sound");
}
Step 2 — Override in derived classes
public class Dog : Animal
{
public override void Speak() => Console.WriteLine("Woof!");
}
public class Cat : Animal
{
public override void Speak() => Console.WriteLine("Meow!");
}
Step 3 — Use polymorphism
Animal myAnimal = new Dog();
myAnimal.Speak(); // Output: "Woof!"
// In a list
List animals = new() { new Dog(), new Cat(), new Dog() };
foreach (var a in animals) a.Speak();
// Output: Woof! Meow! Woof!
Step 4 — Interface polymorphism
interface IDrawable { void Draw(); }
class Circle : IDrawable { public void Draw() => Console.WriteLine("Drawing Circle"); }
class Square : IDrawable { public void Draw() => Console.WriteLine("Drawing Square"); }
List drawables = new() { new Circle(), new Square() };
foreach (var d in drawables) d.Draw();
Interfaces provide polymorphism without inheritance, allowing unrelated classes to be treated uniformly.
Simple Example
public abstract class Shape
{
public abstract double Area();
}
public class Circle : Shape
{
public double Radius { get; set; }
public override double Area() => Math.PI * Radius * Radius;
}
public class Rectangle : Shape
{
public double Width { get; set; }
public double Height { get; set; }
public override double Area() => Width * Height;
}
// Polymorphic usage
var shapes = new List
{
new Circle { Radius = 5 },
new Rectangle { Width = 4, Height = 6 }
};
double total = 0;
foreach (var shape in shapes)
total += shape.Area(); // Each shape computes its own area
Console.WriteLine($"Total area: {total:F2}"); // Total area: 102.54
Code → Meaning → Result
Shapeis abstract — it defines a contract.CircleandRectangleprovide their ownArea().- The loop works with any
Shape; noifstatements needed.
Real-World Example
Consider a payment processing system that supports multiple payment providers (e.g., Credit Card, PayPal, Crypto). Each provider has its own implementation details, but they all conform to a common interface.
public interface IPaymentProcessor
{
bool ProcessPayment(decimal amount);
string GetProviderName();
}
public class CreditCardProcessor : IPaymentProcessor
{
public bool ProcessPayment(decimal amount)
{
Console.WriteLine($"Processing ${amount} via Credit Card");
return true; // pretend success
}
public string GetProviderName() => "Credit Card";
}
public class PayPalProcessor : IPaymentProcessor
{
public bool ProcessPayment(decimal amount)
{
Console.WriteLine($"Processing ${amount} via PayPal");
return true;
}
public string GetProviderName() => "PayPal";
}
public class CryptoProcessor : IPaymentProcessor
{
public bool ProcessPayment(decimal amount)
{
Console.WriteLine($"Processing ${amount} via Crypto");
return true;
}
public string GetProviderName() => "Crypto";
}
// Payment service that uses polymorphism
public class PaymentService
{
private readonly List<IPaymentProcessor> _processors;
public PaymentService(IEnumerable<IPaymentProcessor> processors)
=> _processors = processors.ToList();
public void ProcessAll(decimal amount)
{
foreach (var processor in _processors)
{
bool success = processor.ProcessPayment(amount);
Console.WriteLine($"{processor.GetProviderName()}: {(success ? "Success" : "Failed")}");
}
}
}
// Usage
var processors = new IPaymentProcessor[]
{
new CreditCardProcessor(),
new PayPalProcessor(),
new CryptoProcessor()
};
var service = new PaymentService(processors);
service.ProcessAll(99.99m);
// Output:
// Processing $99.99 via Credit Card
// Credit Card: Success
// Processing $99.99 via PayPal
// PayPal: Success
// Processing $99.99 via Crypto
// Crypto: Success
Why this is realistic:
- The
PaymentServicedoesn't care about the concrete processor type — it works with anyIPaymentProcessor. - Adding a new processor (e.g.,
BankTransferProcessor) doesn't require changing thePaymentService— it just needs to implement the interface. - This is a classic example of the Strategy pattern, enabled by polymorphism.
Analogy
Think of polymorphism like a universal remote with a "Play" button. You point it at a TV, a DVD player, or a streaming device — they all respond to "Play," but each device does something different (turns on, starts spinning, or loads the next episode). You don't need a separate remote for each device; the same command works on all of them.
Compile‑time polymorphism (overloading) is like having multiple recipes for potatoes: mash, fry, or bake. You call "cook(potatoes)" with different parameters (temperature, duration) and the correct recipe is chosen at compile time.
A manager, engineer, and designer all receive the same instruction: "Complete the project." Each does it differently, based on their own expertise — but the person giving the instruction doesn't need to know which role they're talking to.
Under the Hood
How does the runtime know which method to call? It uses a virtual method table (vtable).
The compiler generates IL that calls the method via the callvirt instruction, which indicates a virtual call.
Each object has a type handle pointing to its method table. The method table contains pointers to the actual implementations of all virtual methods.
Animal vtable: Speak → Animal.Speak() Dog vtable: Speak → Dog.Speak() (overridden)
When myAnimal.Speak() is called, the runtime looks up the object's type, finds the method table entry for Speak, and jumps to that address. This happens every time the method is called.
The JIT compiler can devirtualize calls in some cases (e.g., when the type is known to be sealed) to avoid the vtable lookup, improving performance.
Interfaces use a similar mechanism but with interface tables (ITables). The runtime resolves the interface method to the implementing class's method.
Common Confusion
1. Overloading vs Overriding
- Overloading — same method name, different parameters. Resolved at compile time (static polymorphism).
- Overriding — redefining a virtual/abstract method in a derived class. Resolved at runtime (dynamic polymorphism).
2. Virtual vs Abstract vs Interface
virtual— provides a default implementation that can be overridden.abstract— no implementation; must be overridden in a derived class (only in abstract classes).interface— a contract with no implementation; classes must implement all members.
3. Polymorphism only works with virtual methods
Non‑virtual methods are resolved at compile time based on the reference type, not the object type. If you call a non‑virtual method on a base reference, the base implementation is used, even if the derived class defines a method with the same name (this is hiding, not overriding).
Common Mistakes
Mistake 1 — Forgetting override keyword
If you omit override, you're hiding the base method, not overriding it. This can lead to unexpected behavior when using polymorphism.
class Base { public virtual void M() => Console.WriteLine("Base"); }
class Derived : Base { public void M() => Console.WriteLine("Derived"); } // hiding
Base obj = new Derived();
obj.M(); // Output: "Base" (not "Derived")
Correct: Use public override void M().
Mistake 2 — Not calling base when needed
In an overridden method, you may need to call the base implementation to preserve essential behavior (e.g., initialization, base logic). Forgetting this can break functionality.
public override void Save()
{
// if base.Save() is essential (e.g., logging), call it
base.Save();
// additional logic
}
Mistake 3 — Using new to hide base members unintentionally
The new keyword is used to hide a base member intentionally. However, it's rarely needed and can confuse readers. If you're not sure, use override instead (if the base is virtual).
Mistake 4 — Overusing is and as to check types in polymorphic code
If you find yourself using is or as to branch on the concrete type, you're likely bypassing polymorphism. Instead, add a virtual method to the base class to handle the behavior.
When Should I Use It?
Use polymorphism when:
- You have a family of related types that should be handled uniformly.
- You want to write code that is extensible and adheres to the Open/Closed Principle (open for extension, closed for modification).
- You need to pass collections of objects and invoke behavior without knowing their concrete types.
- You are designing a framework or library where users can implement their own plugins.
Avoid polymorphism when:
- The types are not truly related; use composition or separate methods instead.
- You need high performance in the hottest paths and want to avoid virtual dispatch (though the impact is usually negligible).
- The behavior is fixed and won't change — simple non‑virtual code may be clearer.
Mental Model
Virtual/override = the derived class provides its own version of a method.
Runtime dispatch = the correct version is chosen based on the actual object type.
Remember:
· Polymorphism lets you write code that works with any subtype.
· It's the "P" in SOLID (Liskov Substitution Principle).
· Use
virtual for extensibility, sealed to prevent it.
Key Takeaway
- Polymorphism enables a single interface to represent different underlying forms (data types).
- Runtime polymorphism (virtual/override) is the most powerful form, allowing dynamic dispatch.
- Compile‑time polymorphism (overloading) provides convenience and performance.
- Interfaces offer polymorphism without a class hierarchy — ideal for cross‑cutting behaviors.
- Use polymorphism to build extensible, maintainable, and testable systems.
- In .NET 10 / C# 14, polymorphism remains a cornerstone of object‑oriented programming, with enhancements like
virtualdefault interface methods.
Check Your Understanding
You've learned about polymorphism and how it enables flexible designs. Let's see if you can apply these concepts correctly.
1. Which of the following is an example of runtime polymorphism?
Show answer
Correct: C
Why C is correct: Overriding a virtual method uses the object's actual type to determine which method implementation to call at runtime. This is the classic example of runtime polymorphism.
Why A and B are incorrect: Overloading and operator overloading are resolved at compile time (static polymorphism).
Why D is incorrect: Using new hides a base member, but the call is resolved at compile time based on the reference type, not the object type.
Reinforcement: Runtime polymorphism is achieved through inheritance and virtual/override members.
2. What is the purpose of the base keyword in a derived class?
Show answer
Correct: B
Why B is correct: The base keyword allows you to access members (methods, properties, constructors) of the base class from within a derived class. It's commonly used to call the base constructor or to invoke the base implementation of an overridden method.
Why A is incorrect: base is not used for static methods.
Why C is incorrect: To create a new instance, you use new BaseClass(), not base.
Why D is incorrect: sealed prevents overriding, not base.
Reinforcement: Use base to invoke base class functionality.
3. Consider the following code. What is the output?
class A { public virtual void M() => Console.Write("A"); }
class B : A { public override void M() => Console.Write("B"); }
A obj = new B();
obj.M();
Show answer
Correct: B
Why B is correct: Even though obj is declared as A, it actually references a B object. Because M() is virtual and overridden, the runtime calls B.M(), printing "B".
Why A is incorrect: That would happen if the method were not virtual or if the variable were used without polymorphism.
Why C is incorrect: Only one method is called.
Why D is incorrect: The code compiles and runs.
Reinforcement: Polymorphism ensures that the overridden method is called, not the base method.
4. Which of the following is a benefit of using interfaces for polymorphism?
Show answer
Correct: B
Why B is correct: Interfaces define a contract that any class (regardless of its inheritance hierarchy) can implement. This allows completely unrelated classes to be treated polymorphically through that interface.
Why A is incorrect: Interfaces traditionally cannot contain implementation (though default interface methods are available in later C#, but the main purpose is still abstraction).
Why C is incorrect: Interface dispatch can be slightly slower than virtual method calls, but the difference is usually negligible.
Why D is incorrect: A class can implement multiple interfaces, but interfaces themselves don't support inheritance (they can extend other interfaces). The benefit is that a class can implement many interfaces.
Reinforcement: Interfaces provide polymorphism across disparate class hierarchies.
5. What does the sealed keyword do in the context of polymorphism?
Show answer
Correct: B
Why B is correct: When applied to a class, sealed prevents inheritance. When applied to a method, it prevents derived classes from overriding that method (a sealed override).
Why A is incorrect: Sealed classes cannot be inherited.
Why C is incorrect: abstract marks a method as requiring implementation.
Why D is incorrect: abstract forces overriding; sealed prevents it.
Reinforcement: Use sealed to intentionally limit polymorphism.
You now have a deep understanding of polymorphism — from virtual dispatch to interface‑based design!