Skip to content

C# Polymorphism — Method Overloading, Overriding, and Abstract Classes

DodaTech Updated 2026-06-28 9 min read

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. UI frameworks like WPF use virtual methods for custom rendering. Logging frameworks use polymorphic loggers for different outputs.

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:

  1. Number of parameters
  2. Type compatibility (closest match wins)
  3. Parameter order
  4. 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

  1. What is the difference between compile-time and runtime polymorphism?
  2. How does the compiler resolve which overloaded method to call?
  3. Why can you not overload a method by changing only the return type?
  4. What would happen if you omit override on a method that matches a base virtual method?
  5. 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

Can I overload across base and derived classes?

Yes. If a derived class declares a method with the same name but different parameters, the base class methods are still available through the derived type. This combines inheritance and overloading.

What is the difference between polymorphism and inheritance?

Inheritance is the mechanism to derive a class from another. Polymorphism is the ability to use that inheritance to treat objects of different types uniformly through their common base type.

Can operator overloading be used with reference types?

Yes, but typically operator overloading is used with structs or immutable classes. Overloading operators for mutable reference types can be confusing.

What is the `dynamic` type's relationship to polymorphism?

dynamic enables a different form of runtime dispatch (DLR) that bypasses compile-time checking. This is not the same as virtual method polymorphism and is slower.

Can I override a method and change its access modifier?

No. An override method must have the same accessibility as the base virtual method. If the base method is public, the override must be public.

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