C# Polymorphism: Overloading, Overriding, and Interfaces
c# polymorphism: Learn how C# polymorphism works through method overloading, virtual dispatch, abstract classes, and interfaces, with practical examples and design gui...
C# polymorphism lets a single interface or base type represent multiple concrete implementations, so calling code can operate on the abstraction while the runtime selects the actual behavior. C# supports two distinct forms: compile-time polymorphism through method overloading, and runtime polymorphism through virtual dispatch. Understanding both forms matters because they solve different problems and carry different costs.
The Two Kinds of Polymorphism in C#
C# distinguishes between static (compile-time) polymorphism and dynamic (runtime) polymorphism. Static polymorphism is resolved by the compiler when it selects which overloaded method to call based on the declared types of the arguments. Dynamic polymorphism is resolved at runtime when the virtual method table of the actual object determines which override executes.
The distinction matters in practice. With compile-time polymorphism, the decision is fixed when the code is built. With runtime polymorphism, the decision depends on the actual runtime type of the object, which may not be known until the program executes.
Compile-Time Polymorphism Through Overloading
Method overloading is the simplest form of polymorphism in C#. Multiple methods share the same name but differ in their parameter lists. The compiler picks the correct overload based on the number and types of arguments at the call site.
public class Logger { public void Log(string message) { Console.WriteLine($"[INFO] {message}"); } public void Log(string message, Exception ex) { Console.WriteLine($"[ERROR] {message}: {ex.Message}"); } public void Log(string message, int severity) { Console.WriteLine($"[{severity}] {message}"); } }
The compiler resolves the call logger.Log("started") to the single-parameter overload, logger.Log("failed", ex) to the two-parameter overload with Exception, and so on. This resolution happens entirely at compile time, so there is no runtime dispatch cost. The tradeoff is that the caller must provide arguments that match one of the declared signatures exactly; there is no way to add new overloads without modifying the class.
Overloading is most useful when the same logical operation accepts different input shapes. It keeps the public API surface coherent without forcing callers to remember different method names.
Runtime Polymorphism with Virtual Methods
Runtime polymorphism in C# is built on virtual methods. A base class declares a method as virtual, and a derived class can replace the implementation with override. When a call is made through a base-class reference, the runtime consults the object's actual type and invokes the most derived override.
public class Shape { public virtual double Area() { return 0; } } public class Circle : Shape { private readonly double _radius; public Circle(double radius) { _radius = radius; } public override double Area() { return Math.PI * _radius * _radius; } } public class Rectangle : Shape { private readonly double _width; private readonly double _height; public Rectangle(double width, double height) { _width = width; _height = height; } public override double Area() { return _width * _height; } }
Calling shape.Area() through a Shape reference produces different results depending on whether the actual object is a Circle or a Rectangle. The caller does not need to know the concrete type; it only needs to know that the object is a Shape and that Area() is part of the contract.
This is the pattern that enables polymorphic collections, dependency injection, and plugin-style architectures. A method that accepts Shape can process any derived type without a switch statement or type check.
Abstract Classes and Interfaces as Polymorphic Contracts
Abstract classes and interfaces formalize the polymorphic contract. An abstract class can provide shared implementation while forcing derived classes to implement certain members. An interface declares only the contract, with no implementation at all.
public abstract class PaymentProcessor { public abstract PaymentResult Process(PaymentRequest request); protected void LogStart(PaymentRequest request) { Console.WriteLine($"Processing {request.Amount} for {request.Currency}"); } } public interface IPaymentGateway { PaymentResult Process(PaymentRequest request); bool SupportsCurrency(string currencyCode); }
The choice between an abstract class and an interface depends on whether the derived types share implementation details. An abstract class is appropriate when multiple derived classes need the same helper methods or state. An interface is appropriate when the contract is the only thing that matters and the implementations have nothing in common.
A class can implement multiple interfaces but inherit from only one base class. That constraint often drives the decision: if a type already inherits from another class and still needs a polymorphic contract, an interface is the only option.
The Difference Between override and new
A common source of confusion in C# polymorphism is the difference between override and new when a derived class declares a method with the same signature as a base-class method.
override replaces the base implementation in the virtual dispatch chain. A call through a base-class reference invokes the derived implementation.
new hides the base method. The derived method is only called when the reference type is the derived type. A base-class reference still invokes the base implementation.
public class Base { public virtual void Describe() { Console.WriteLine("Base"); } } public class DerivedOverride : Base { public override void Describe() { Console.WriteLine("DerivedOverride"); } } public class DerivedNew : Base { public new void Describe() { Console.WriteLine("DerivedNew"); } }
Base a = new DerivedOverride(); Base b = new DerivedNew(); a.Describe(); // DerivedOverride b.Describe(); // Base
The new keyword breaks the polymorphic behavior. If a method is hidden with new, callers holding a base-class reference get the base implementation, which can lead to surprising behavior when the code mixes reference types. In most cases, override is the intended choice; new is rarely the right tool and should be used only when the derived class genuinely needs a method with the same signature but no polymorphic relationship with the base.
Dispatch Cost and Performance Considerations
Virtual method calls are slightly more expensive than non-virtual calls because the runtime must look up the target method in the object's virtual method table. In most application code, this cost is negligible. The JIT compiler can often devirtualize calls when it can prove the concrete type at the call site, eliminating the lookup entirely.
The larger performance concern is not the dispatch itself but the design choices that surround it. Deep inheritance hierarchies with many levels of overrides can make the code harder to reason about, and excessive use of polymorphism for simple conditional logic can obscure the actual behavior.
When performance is genuinely critical, such as in a hot loop processing millions of objects, the dispatch cost may matter. In that case, an alternative is to avoid polymorphic calls entirely by switching on a type discriminator or by using generic specialization. But that is an optimization to apply only after profiling shows the dispatch cost is significant. Prematurely replacing polymorphism with manual type checks usually makes the code less maintainable for no measurable gain.
Choosing the Right Polymorphic Approach
The decision between overloading, virtual methods, abstract classes, and interfaces comes down to what the code needs to express.
Overloading is for compile-time variation based on argument types. Use it when the same operation accepts different input shapes and the behavior can be determined statically.
Virtual methods are for runtime variation where the caller holds a base-class reference and the actual type determines the behavior. Use them when the set of derived types may grow and the caller should not need to know about each one.
Abstract classes are for runtime variation with shared implementation. Use them when derived types share state or helper logic.
Interfaces are for runtime variation with no shared implementation. Use them when the contract is the only commonality and when a type needs to participate in multiple polymorphic relationships.
A common mistake is to use polymorphism everywhere, even when a simple conditional would be clearer. If the set of types is fixed and small, a switch statement over a type discriminator is often more readable than a class hierarchy. Polymorphism pays off when the set of types is open, when new types will be added without modifying existing callers, or when the behavior is genuinely different per type.