Polymorphism
By the end of this lesson
Treat different types through a shared contract using virtual and override.
Here is the idea, before the vocabulary. You want to write one loop that pays every employee, without that loop knowing or caring how each kind of employee is paid. Sales staff get commission, hourly staff get rate times hours, salaried staff get a twelfth of their annual figure. The loop calls one method and the right calculation happens.
That is polymorphism: the variable's declared type decides what you are allowed to call, and the object's actual type decides what runs. Two different things, and keeping them apart in your head is most of the lesson.
public class Employee
{
public string Name { get; }
public decimal BaseMonthlyPay { get; }
public Employee(string name, decimal baseMonthlyPay)
{
Name = name;
BaseMonthlyPay = baseMonthlyPay;
}
public virtual decimal CalculateMonthlyPay()
{
return BaseMonthlyPay;
}
}
public class SalesEmployee : Employee
{
public decimal MonthlySales { get; }
public SalesEmployee(string name, decimal baseMonthlyPay, decimal monthlySales)
: base(name, baseMonthlyPay)
{
MonthlySales = monthlySales;
}
public override decimal CalculateMonthlyPay()
{
return base.CalculateMonthlyPay() + (MonthlySales * 0.05m);
}
}
public class HourlyEmployee : Employee
{
public decimal HourlyRate { get; }
public int HoursWorked { get; }
public HourlyEmployee(string name, decimal hourlyRate, int hoursWorked)
: base(name, 0m)
{
HourlyRate = hourlyRate;
HoursWorked = hoursWorked;
}
public override decimal CalculateMonthlyPay()
{
return HourlyRate * HoursWorked;
}
}
List<Employee> payroll = new List<Employee>
{
new Employee("Asha", 100_000m),
new SalesEmployee("Ravi", 60_000m, 800_000m),
new HourlyEmployee("Meera", 900m, 120)
};
foreach (Employee employee in payroll)
{
Console.WriteLine($"{employee.Name,-8} {employee.CalculateMonthlyPay(),12:N2}");
}- virtual on the base method means "a derived class may replace this". Without it, a derived class cannot override.
- override on the derived method means "replace the base version". The signature must match exactly.
- base.CalculateMonthlyPay() calls the version this method replaced. SalesEmployee uses it to add commission on top of base pay rather than restating the base calculation.
- The loop variable is declared Employee, so the compiler allows only Employee members. At run time the object decides which CalculateMonthlyPay body executes.
- Adding a ContractEmployee next month requires no change to this loop. That is the property being bought.
The keywords involved, and what each one commits you to:
- virtual
- Marks a base member as replaceable. It is a promise that derived classes may change this behaviour and the rest of your class will cope.
- override
- Replaces a virtual (or abstract) member. The replacement is used no matter what type the variable holding the object is declared as.
- base.Member()
- Calls the version you replaced, from inside the override. Use it when your behaviour is "the original plus something".
- sealed override
- Overrides, and stops anyone below you overriding again. Useful when your version establishes something the next level down must not break.
- new
- Hides the base member rather than overriding it. Almost never what you want — the next section shows why.
override against new: the difference that matters
public class InvoiceReport
{
public virtual string Render()
{
return "Standard invoice report";
}
}
public class SummaryInvoiceReport : InvoiceReport
{
public override string Render() // replaces the base version
{
return "Summary invoice report";
}
}
public class LegacyInvoiceReport : InvoiceReport
{
public new string Render() // hides the base version
{
return "Legacy invoice report";
}
}
InvoiceReport first = new SummaryInvoiceReport();
InvoiceReport second = new LegacyInvoiceReport();
Console.WriteLine(first.Render()); // Summary invoice report
Console.WriteLine(second.Render()); // Standard invoice report
Console.WriteLine(((LegacyInvoiceReport)second).Render()); // Legacy invoice report- first is declared InvoiceReport and holds a SummaryInvoiceReport. Because Render was overridden, the derived version runs. The object won.
- second is declared InvoiceReport and holds a LegacyInvoiceReport. Because Render was hidden with new, the base version runs. The declared type won.
- The third line casts to the derived type, and now the hidden version runs. The same object gives two different answers depending on how the variable is typed.
- That inconsistency is why new is a warning sign. If you omit both keywords the compiler warns you and treats the method as hidden, so an accidental hide usually starts as an ignored warning.
public class Order
{
public Order()
{
// Looks harmless. It is not.
Console.WriteLine(Describe());
}
public virtual string Describe()
{
return "Order";
}
}
public class SubscriptionOrder : Order
{
private readonly string _planName;
public SubscriptionOrder(string planName)
{
_planName = planName;
}
public override string Describe()
{
return $"Subscription order for {_planName.ToUpperInvariant()}";
}
}
Order order = new SubscriptionOrder("Pro"); // NullReferenceException- Constructors run base first, derived second. So the Order constructor body executes before any line of the SubscriptionOrder constructor.
- The object is already a SubscriptionOrder, so Describe() dispatches to the override — even though the derived constructor has not run yet.
- _planName is therefore still null. Calling ToUpperInvariant() on it throws, from a constructor, with a stack trace that points at a base class the author of SubscriptionOrder may never have opened.
- Nothing about SubscriptionOrder is wrong. The fault is in the base class calling a virtual member during construction, which hands control to a subclass before that subclass is ready.
Summary
- The declared type decides what you may call; the object's actual type decides what runs
- virtual opts a base member in to replacement, override replaces it, base.Member() calls the replaced version
- new hides rather than overrides, so behaviour depends on the variable's declared type — avoid it
- Never call a virtual member from a constructor: the override runs before the subclass is initialised
- Polymorphism trades easy reading for easy extension — new types need no change to existing loops
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Try it yourself
Add a ContractEmployee to the payroll example. It is paid a day rate for days worked, and it receives no base pay.
Add it to the list and run the loop. Confirm you did not have to change the loop at all.
Show solution
The loop is untouched because it depends on the Employee contract, not on the set of employee types. Every new kind of employee is one new class and one new list entry.
Passing 0m for base pay is a hint worth noticing: ContractEmployee inherits a BaseMonthlyPay property that means nothing for it. When several subclasses start ignoring inherited members, the base class is describing the types loosely rather than accurately — which is the situation the next lesson, on abstract classes, addresses.
public class ContractEmployee : Employee
{
public decimal DayRate { get; }
public int DaysWorked { get; }
public ContractEmployee(string name, decimal dayRate, int daysWorked)
: base(name, 0m)
{
DayRate = dayRate;
DaysWorked = daysWorked;
}
public override decimal CalculateMonthlyPay()
{
return DayRate * DaysWorked;
}
}Think about it
Think about it
You are reading unfamiliar code. A variable is declared as Employee and the next line calls CalculateMonthlyPay(). You want to know which code actually runs.
What do you need to find out, and why can the answer differ between two runs of the same program?
Show solution
You need the object's real type, which is decided wherever the object was created — possibly far away, possibly by configuration, possibly by a database row. The declared type tells you only what may be called.
Two runs can differ because the creation decision can depend on input. A payroll job reading employee records might construct a SalesEmployee on one row and an HourlyEmployee on the next, and the same line of code dispatches differently each time.
This is polymorphism working as intended, and it is also the honest cost of it: code becomes harder to follow by reading, because the answer is not in the file you are looking at. That is the trade for being able to add types without editing the loop.
Knowledge check
Nothing is recorded and there is no score. The explanation appears either way.
Saved in this browser only.