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.
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.
Polymorphism in C# comes in two flavors:
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.
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.
Polymorphism solves this by:
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.
public class Animal
{
public virtual void Speak() => Console.WriteLine("Animal sound");
}
public class Dog : Animal
{
public override void Speak() => Console.WriteLine("Woof!");
}
public class Cat : Animal
{
public override void Speak() => Console.WriteLine("Meow!");
}
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!
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.
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
Shape is abstract — it defines a contract.Circle and Rectangle provide their own Area().Shape; no if statements needed.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:
PaymentService doesn't care about the concrete processor type — it works with any IPaymentProcessor.BankTransferProcessor) doesn't require changing the PaymentService — it just needs to implement the interface.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.
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.
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.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).
override keywordIf 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().
base when neededIn 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
}
new to hide base members unintentionallyThe 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).
is and as to check types in polymorphic codeIf 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.
virtual for extensibility, sealed to prevent it.
virtual default interface methods.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?
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?
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();
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?
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?
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!
dotnetmadeeasy.com — Learn C# and .NET, the right way.