C# Polymorphism — Method Overloading, Overriding, and Abstract Classes
In this tutorial, you will learn about C# Polymorphism. We cover key concepts, practical examples, and best practices to help you master this topic.
C# polymorphism allows objects of different types to respond to the same method call with type-specific behavior, achieved through method overloading at compile time and method overriding at runtime.
What You'll Learn
You will master polymorphism in C#: compile-time polymorphism through method overloading and operator overloading, runtime polymorphism through virtual method overriding, abstract classes and methods as contracts for derived classes, and how polymorphic behavior enables extensible frameworks in .NET.
Why It Matters
Polymorphism is the third pillar of OOP and the key to writing extensible, maintainable code. It allows you to write code that works with the base type while the runtime dispatches to the correct derived implementation. This is how frameworks like ASP.NET Core can invoke your controller methods without knowing their types at compile time.
Real-World Use
ASP.NET Core's middleware pipeline uses polymorphism to chain request handlers. Stream classes (FileStream, MemoryStream, NetworkStream) all inherit from the abstract Stream class. LINQ uses polymorphism through IEnumerable
Learning Path
graph LR
A["12: Inheritance"] --> B["13: Polymorphism"]
B --> C["14: Interfaces"]
C --> D["15: Records"]
D --> E["16: Structs"]
style A fill:#4a90d9,stroke:#2c5f8a,color:#fff
style B fill:#4a90d9,stroke:#2c5f8a,color:#fff
style C fill:#4a90d9,stroke:#2c5f8a,color:#fff
style D fill:#4a90d9,stroke:#2c5f8a,color:#fff
style E fill:#4a90d9,stroke:#2c5f8a,color:#fff
Compile-Time Polymorphism (Method Overloading)
Multiple methods with the same name but different parameters:
public class Calculator
{
// Overloaded by parameter count
public int Add(int a, int b) => a + b;
public int Add(int a, int b, int c) => a + b + c;
// Overloaded by parameter types
public double Add(double a, double b) => a + b;
public decimal Add(decimal a, decimal b) => a + b;
// Overloaded by parameter order
public string Format(int number, string prefix) => $"{prefix}{number}";
public string Format(string prefix, int number) => $"{prefix}{number}";
}
var calc = new Calculator();
Console.WriteLine(calc.Add(2, 3)); // 5
Console.WriteLine(calc.Add(2, 3, 4)); // 9
Console.WriteLine(calc.Add(2.5, 3.7)); // 6.2
Overload Resolution Rules
The compiler selects the best match based on:
- Number of parameters
- Type compatibility (closest match wins)
- Parameter order
- Optional parameters (least specific after removing defaults)
public class Display
{
public void Show(int x) => Console.WriteLine($"int: {x}");
public void Show(double x) => Console.WriteLine($"double: {x}");
public void Show(string x) => Console.WriteLine($"string: {x}");
public void Show<T>(T x) => Console.WriteLine($"generic: {x}"); // Last resort
}
var d = new Display();
d.Show(42); // int: 42 (exact match)
d.Show(3.14); // double: 3.14 (exact match)
d.Show("Hello"); // string: Hello (exact match)
d.Show(42L); // generic: 42 (no exact match, generic wins)
Runtime Polymorphism (Method Overriding)
Runtime polymorphism uses virtual and override to dispatch method calls based on the actual runtime type:
public class Notification
{
public string Recipient { get; set; }
public string Message { get; set; }
public virtual void Send()
{
Console.WriteLine($"Sending notification to {Recipient}");
}
}
public class EmailNotification : Notification
{
public override void Send()
{
// Base.Send() is not called to show different behavior
Console.WriteLine($"Email to {Recipient}: {Message}");
}
}
public class SmsNotification : Notification
{
public override void Send()
{
Console.WriteLine($"SMS to {Recipient}: {Message}");
}
}
public class PushNotification : Notification
{
public override void Send()
{
Console.WriteLine($"Push to device {Recipient}: {Message}");
}
}
Polymorphic Dispatch
var notifications = new List<Notification>
{
new EmailNotification { Recipient = "alice@example.com", Message = "Welcome!" },
new SmsNotification { Recipient = "+1234567890", Message = "Your code is 1234" },
new PushNotification { Recipient = "device-42", Message = "New message" }
};
// Single loop dispatches to the correct Send() for each type
foreach (var notification in notifications)
{
notification.Send();
}
Expected output:
Email to alice@example.com: Welcome!
SMS to +1234567890: Your code is 1234
Push to device device-42: New message
Polymorphism with Parameters
public class PaymentProcessor
{
public void ProcessPayment(Notification notification, decimal amount)
{
// Polymorphic call regardless of notification type
notification.Send();
Console.WriteLine($" Amount: {amount:C}");
}
}
var processor = new PaymentProcessor();
processor.ProcessPayment(new EmailNotification
{
Recipient = "billing@example.com",
Message = "Payment received"
}, 99.99m);
Abstract Classes and Polymorphism
Abstract classes enforce polymorphic contracts:
public abstract class DocumentExporter
{
public string FileName { get; set; }
// Abstract method: every exporter must implement
public abstract void Export(string content);
// Virtual method with optional override
public virtual void BeforeExport()
{
Console.WriteLine($"Preparing to export to {FileName}");
}
// Template method pattern
public void ExportDocument(string content)
{
BeforeExport();
Export(content);
AfterExport();
}
protected virtual void AfterExport()
{
Console.WriteLine("Export completed");
}
}
public class PdfExporter : DocumentExporter
{
public override void Export(string content)
{
Console.WriteLine($"Generating PDF: {content.Substring(0, Math.Min(20, content.Length))}...");
}
protected override void AfterExport()
{
Console.WriteLine("PDF file saved with .pdf extension");
}
}
public class CsvExporter : DocumentExporter
{
public override void Export(string content)
{
Console.WriteLine("Writing CSV rows...");
foreach (var line in content.Split('\n'))
{
Console.WriteLine($" CSV: {line.Trim()}");
}
}
}
public class JsonExporter : DocumentExporter
{
public override void Export(string content)
{
Console.WriteLine($"JSON output: {{\"data\": \"{content.Trim()}\"}}");
}
public override void BeforeExport()
{
// Override completely, no base call
Console.WriteLine($"Exporting JSON to {FileName}.json");
}
}
Using the Exporters Polymorphically
var data = "Name,Age,City\nAlice,30,NYC\nBob,25,LA";
List<DocumentExporter> exporters = new()
{
new PdfExporter { FileName = "report" },
new CsvExporter { FileName = "data" },
new JsonExporter { FileName = "output" }
};
foreach (var exporter in exporters)
{
exporter.ExportDocument(data);
Console.WriteLine();
}
Polymorphism with Interfaces
Interfaces provide polymorphic contracts without implementation:
public interface IShape
{
double CalculateArea();
void Draw();
}
public class Circle : IShape
{
public double Radius { get; set; }
public double CalculateArea() => Math.PI * Radius * Radius;
public void Draw() => Console.WriteLine($"Drawing circle with radius {Radius}");
}
public class Square : IShape
{
public double Side { get; set; }
public double CalculateArea() => Side * Side;
public void Draw() => Console.WriteLine($"Drawing square with side {Side}");
}
List<IShape> shapes = new()
{
new Circle { Radius = 5 },
new Square { Side = 4 }
};
foreach (var shape in shapes)
{
Console.WriteLine($"Area: {shape.CalculateArea():F2}");
shape.Draw();
}
Operator Overloading
A specialized form of compile-time polymorphism:
public struct Vector2D
{
public double X { get; }
public double Y { get; }
public Vector2D(double x, double y) => (X, Y) = (x, y);
public static Vector2D operator +(Vector2D a, Vector2D b)
=> new(a.X + b.X, a.Y + b.Y);
public static Vector2D operator -(Vector2D a, Vector2D b)
=> new(a.X - b.X, a.Y - b.Y);
public static Vector2D operator *(Vector2D v, double scalar)
=> new(v.X * scalar, v.Y * scalar);
public static bool operator ==(Vector2D a, Vector2D b)
=> a.X == b.X && a.Y == b.Y;
public static bool operator !=(Vector2D a, Vector2D b)
=> !(a == b);
public override string ToString() => $"({X}, {Y})";
}
var v1 = new Vector2D(3, 4);
var v2 = new Vector2D(1, 2);
Console.WriteLine($"v1 + v2 = {v1 + v2}"); // (4, 6)
Console.WriteLine($"v1 * 2 = {v1 * 2}"); // (6, 8)
Common Mistakes
Mistake 1: Confusing Overloading with Overriding
Overloading is compile-time, same method name, different parameters. Overriding is runtime, same signature, requires virtual/override keywords.
Mistake 2: Forgetting the override Keyword
Writing a method with the same signature as a base virtual method without override causes a warning and hides the base method with new behavior instead of polymorphic behavior.
Mistake 3: Not Calling base.Method() When Needed
If the base implementation performs essential work (logging, validation, setup), skipping base.Method() in the override breaks that functionality.
Mistake 4: Ambiguous Overloads from Optional Parameters
void Method(int a, int b = 0) { }
void Method(int a) { }
Method(5); // Ambiguous!
Mistake 5: Overloading by Return Type Only
C# does not allow overloading that differs only by return type. The compiler uses the method signature (name + parameters), not the return type.
Mistake 6: Breaking Liskov Substitution Principle
Derived classes should be substitutable for their base classes. If an override throws unexpected exceptions or changes expected behavior, it violates LSP.
Practice Questions
- What is the difference between compile-time and runtime polymorphism?
- How does the compiler resolve which overloaded method to call?
- Why can you not overload a method by changing only the return type?
- What would happen if you omit
overrideon a method that matches a base virtual method? - Write a polymorphic logging system with ConsoleLogger, FileLogger, and DatabaseLogger.
Challenge
Design a polymorphic PaymentMethod system with an abstract ProcessPayment(decimal amount) method. Implement CreditCard, PayPal, and CryptoWallet derived classes. Each should have its own processing logic. Demonstrate polymorphic behavior by processing payments through a single loop.
FAQ
Mini Project
Create a polymorphic logging and monitoring system:
public abstract class LogTarget
{
public string Name { get; set; }
public abstract void WriteLog(string level, string message, Exception? ex = null);
public virtual void Flush() { }
}
public class ConsoleLogTarget : LogTarget
{
public override void WriteLog(string level, string message, Exception? ex = null)
{
var color = level switch
{
"ERROR" => ConsoleColor.Red,
"WARN" => ConsoleColor.Yellow,
"INFO" => ConsoleColor.Green,
_ => ConsoleColor.Gray
};
Console.ForegroundColor = color;
Console.WriteLine($"[{level}] {message}");
if (ex != null) Console.WriteLine($" Exception: {ex.Message}");
Console.ResetColor();
}
}
public class FileLogTarget : LogTarget
{
private readonly string _filePath;
private readonly List<string> _buffer = new();
public FileLogTarget(string filePath) => _filePath = filePath;
public override void WriteLog(string level, string message, Exception? ex = null)
{
var entry = $"[{DateTime.UtcNow:O}] [{level}] {message}";
if (ex != null) entry += $" | {ex.Message}";
_buffer.Add(entry);
if (_buffer.Count >= 10) Flush();
}
public override void Flush()
{
if (_buffer.Count > 0)
{
File.AppendAllLines(_filePath, _buffer);
_buffer.Clear();
}
}
}
public class Logger
{
private readonly List<LogTarget> _targets = new();
public void AddTarget(LogTarget target) => _targets.Add(target);
public void Info(string message) => Log("INFO", message);
public void Warn(string message) => Log("WARN", message);
public void Error(string message, Exception? ex = null) => Log("ERROR", message, ex);
private void Log(string level, string message, Exception? ex = null)
{
foreach (var target in _targets)
{
target.WriteLog(level, message, ex);
}
}
public void FlushAll()
{
foreach (var target in _targets) target.Flush();
}
}
var logger = new Logger();
logger.AddTarget(new ConsoleLogTarget { Name = "Console" });
logger.AddTarget(new FileLogTarget("app.log") { Name = "File" });
logger.Info("Application started");
logger.Warn("Memory usage is high");
logger.Error("Failed to connect to database", new InvalidOperationException("Connection timeout"));
logger.FlushAll();
Expected output (console):
[INFO] Application started
[WARN] Memory usage is high
[ERROR] Failed to connect to database
Exception: Connection timeout
What's Next
You have mastered polymorphism in C#. The next lesson covers interfaces: defining contracts, implementing interfaces, and default interface methods in C# 8.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro