Allowing Derived Classes to Customize Behavior
Myth: virtual methods are slow and should be avoided. Reality: they're the foundation of polymorphism, letting you write code that works with base class references but executes derived class implementations. The performance cost is negligible compared to the flexibility you gain.
Mark a method virtual to allow derived classes to override it with custom implementations. When you call a virtual method through a base class reference, the runtime dispatches to the most-derived override. This lets subclasses specialize behavior while base classes define the interface.
You'll build a payment processing system where different payment types override virtual methods to implement their specific logic. By the end, you'll know when virtual methods enable clean designs and when they introduce unnecessary complexity.
Defining Virtual Methods
Add the virtual keyword to methods you want subclasses to customize. Derived classes use override to provide their implementation. The signature must match exactly.
public class Payment
{
public decimal Amount { get; set; }
public virtual void Process()
{
Console.WriteLine($"Processing payment of ${Amount}");
}
public virtual bool Validate()
{
return Amount > 0;
}
}
public class CreditCardPayment : Payment
{
public string CardNumber { get; set; }
public override void Process()
{
Console.WriteLine($"Charging card {CardNumber} for ${Amount}");
}
public override bool Validate()
{
return base.Validate() && !string.IsNullOrEmpty(CardNumber);
}
}
The base class defines virtual Process and Validate methods. CreditCardPayment overrides both to add card-specific logic. Calling Process on a Payment reference executes the CreditCardPayment version if that's the runtime type.
Polymorphism in Action
Work with base class references but get derived class behavior. The runtime resolves which implementation to call based on the actual object type, not the reference type.
public class PayPalPayment : Payment
{
public string Email { get; set; }
public override void Process()
{
Console.WriteLine($"Sending ${Amount} via PayPal to {Email}");
}
}
public class PaymentProcessor
{
public void ProcessPayments(List<Payment> payments)
{
foreach (var payment in payments)
{
if (payment.Validate())
{
payment.Process();
}
}
}
}
// Usage
var processor = new PaymentProcessor();
var payments = new List<Payment>
{
new CreditCardPayment { Amount = 100, CardNumber = "1234" },
new PayPalPayment { Amount = 50, Email = "user@example.com" }
};
processor.ProcessPayments(payments);
ProcessPayments works with Payment references. The virtual method dispatch ensures each payment type runs its specific implementation. You can add new payment types without changing ProcessPayments. This is polymorphism's power.
Calling Base Implementation
Use base to call the parent class implementation from an override. This lets you extend behavior without replacing it entirely.
public class LoggedPayment : Payment
{
public override void Process()
{
Console.WriteLine($"[LOG] Starting payment processing");
base.Process();
Console.WriteLine($"[LOG] Payment processing complete");
}
}
LoggedPayment adds logging around the base implementation. Using base.Process() calls the parent's version, then the override adds behavior before and after. This is cleaner than duplicating the base logic.
Try It Yourself
Build a shape hierarchy that uses virtual methods for area calculation. This demonstrates how polymorphism lets you work with different shapes uniformly.
Steps
- Create:
dotnet new console -n VirtualDemo
- Navigate:
cd VirtualDemo
- Edit Program.cs
- Update .csproj
- Run:
dotnet run
var shapes = new Shape[]
{
new Circle { Radius = 5 },
new Rectangle { Width = 4, Height = 6 }
};
foreach (var shape in shapes)
{
Console.WriteLine($"{shape.GetType().Name} area: {shape.GetArea():F2}");
}
abstract class Shape
{
public abstract double GetArea();
}
class Circle : Shape
{
public double Radius { get; init; }
public override double GetArea() => Math.PI * Radius * Radius;
}
class Rectangle : Shape
{
public double Width { get; init; }
public double Height { get; init; }
public override double GetArea() => Width * Height;
}
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
Console
Circle area: 78.54
Rectangle area: 24.00
Design Trade-offs
Choose virtual methods when you have a stable abstraction with varying implementations. If you're building a framework or library where users extend your classes, virtual methods provide extension points. They work well for template methods, hooks, and behavior variation within a type hierarchy.
Choose composition with interfaces when implementations differ completely or when you need to swap behavior at runtime without inheritance. Interfaces give you flexibility to combine behaviors from multiple sources. Virtual methods lock you into single inheritance.
If unsure, start with non-virtual methods and add virtual when you discover extension points. Premature virtualization adds complexity and performance cost without benefit. Make methods virtual when you have concrete evidence that subclasses need customization.