Inheritance & Polymorphism

C# · Lesson 18 of 31 6 min read Updated

A school has teachers, clerks and a principal. They are all employees with a name and a salary, but each one's pay is worked out a little differently. Inheritance lets you write the shared parts once in a base class (Employee) and create derived classes (Manager, Intern) that reuse those parts and change only what is different.

The real power comes from polymorphism: code written for Employee automatically works with every kind of employee, each behaving in its own way. In this lesson you will build a small staff payroll and learn the keywords that make inheritance work safely.

Base and derived classes

A derived class names its base class after a colon. It inherits all the base class's members, and can add new ones or replace virtual ones with override.

C#
List<Employee> staff =
[
    new Employee("Priya", 40000m),
    new Manager("Rohan", 65000m, 15000m),
    new Intern("Ananya", 15000m)
];

foreach (Employee e in staff)
{
    Console.WriteLine(e.Describe());
}
Console.WriteLine($"Monthly payroll: Rs {staff.Sum(e => e.MonthlyPay())}");

class Employee
{
    public string Name { get; }
    protected decimal BaseSalary { get; }

    public Employee(string name, decimal baseSalary)
    {
        Name = name;
        BaseSalary = baseSalary;
    }

    public virtual decimal MonthlyPay() => BaseSalary;

    public string Describe() => $"{GetType().Name,-8} {Name,-7} Rs {MonthlyPay()}";
}

class Manager : Employee
{
    private readonly decimal _allowance;

    public Manager(string name, decimal baseSalary, decimal allowance)
        : base(name, baseSalary)
    {
        _allowance = allowance;
    }

    public override decimal MonthlyPay() => BaseSalary + _allowance;
}

class Intern : Employee
{
    public Intern(string name, decimal stipend) : base(name, stipend) { }

    public override decimal MonthlyPay() => base.MonthlyPay() + 2000m; // travel allowance
}
Output
Employee Priya   Rs 40000
Manager  Rohan   Rs 80000
Intern   Ananya  Rs 17000
Monthly payroll: Rs 137000

Here is what each piece does:

  • class Manager : Employee means "a Manager is an Employee". It inherits Name, BaseSalary, MonthlyPay and Describe.
  • : base(name, baseSalary) calls the base class constructor, so the shared fields are set up in one place.
  • protected members are visible to the class and its derived classes, but not to outside code. BaseSalary is protected so Manager can use it while Program.cs cannot.
  • virtual in the base class says "derived classes may replace this method"; override in the derived class replaces it.
  • base.MonthlyPay() calls the base class version from inside an override, so the intern's pay builds on the normal calculation.

Polymorphism

Look again at the loop. It only knows about Employee, yet each object used its own MonthlyPay. That is polymorphism ("many forms"): the method that runs depends on the object's real type at run time, not on the variable's type. Describe lives only in Employee, but its call to MonthlyPay() still reaches the override. GetType().Name shows that real type.

This is why inheritance is useful. You can add a Principal class next year, and the payroll loop works without a single change.

Constructor order and sealed classes

When you create a derived object, the base constructor always runs first, then the derived constructor. A sealed class cannot be inherited from at all.

C#
var b = new SleeperBerth();

class Berth
{
    public Berth() => Console.WriteLine("Berth constructor");
}

sealed class SleeperBerth : Berth
{
    public SleeperBerth() => Console.WriteLine("SleeperBerth constructor");
}
Output
Berth constructor
SleeperBerth constructor

Trying class UpperBerth : SleeperBerth would fail with error CS0509, because SleeperBerth is sealed. Seal classes that were not designed for inheritance; it prevents surprises and lets the runtime optimise calls. Every class ultimately inherits from object, which is where ToString, Equals and GetType come from.

Checking types: is and as

Sometimes you need to know whether a base-type variable holds a particular derived type. The is pattern tests the type and gives you a typed variable in one step. The as operator tries the conversion and returns null if it fails.

C#
Employee[] team = [new Manager("Rohan", 65000m, 15000m), new Intern("Ananya", 15000m)];

foreach (Employee e in team)
{
    if (e is Manager m)
    {
        Console.WriteLine($"{m.Name} is a manager");
    }
    else
    {
        Console.WriteLine($"{e.Name} is not a manager");
    }
}

Manager? maybe = team[1] as Manager;
Console.WriteLine(maybe is null);
Output
Rohan is a manager
Ananya is not a manager
True

A cast like (Manager)team[1] would throw InvalidCastException, so prefer is when you are not sure. If you find yourself checking types often, it usually means the behaviour belongs in a virtual method instead.

override vs new

If a derived class declares a method with the same name as a non-virtual base method, it hides it rather than overriding it. The compiler warns you, and you can silence the warning with the new modifier. The difference shows up when you call through a base-type variable:

C#
Notification n = new SmsNotification();
n.Send("OTP 4821");
n.Log();

class Notification
{
    public virtual void Send(string text) => Console.WriteLine($"Generic: {text}");
    public void Log() => Console.WriteLine("Logged by Notification");
}

class SmsNotification : Notification
{
    public override void Send(string text) => Console.WriteLine($"SMS: {text}");
    public new void Log() => Console.WriteLine("Logged by SmsNotification");
}
Output
SMS: OTP 4821
Logged by Notification

Send is overridden, so the SMS version runs. Log is only hidden, so a Notification variable still calls the base version. Hiding is almost never what you want; use virtual and override.

Common mistakes

  • Overriding a method that is not virtual. You get CS0506: cannot override inherited member ... because it is not marked virtual, abstract, or override. Mark the base method virtual.
  • Forgetting override. Without it, the method hides the base one and you get warning CS0114; polymorphism silently stops working.
  • Forgetting the base constructor. If the base class has no parameterless constructor, the derived constructor must call : base(...).
  • Making everything public. Use protected for members only derived classes should see.
  • Using inheritance just to reuse code. If the "is a" sentence sounds wrong, use composition.

Key points

  • class Derived : Base inherits the base class's members; C# allows one base class.
  • : base(...) calls the base constructor, which runs before the derived one.
  • Mark methods virtual in the base and override them in derived classes.
  • Polymorphism: a base-type variable calls the real object's override at run time.
  • protected members are visible to derived classes; sealed stops further inheritance.
  • Use is patterns to test types safely; avoid hiding methods with new.

Exercise

Create a base class Ticket with Passenger and BaseFare properties and a virtual method Fare() that returns the base fare. Derive TatkalTicket, which adds 30% to the base fare, and SeniorTicket, which gives 40% off. Override ToString in the base class to show the type name, passenger and fare. Put three tickets (Kabir normal Rs 800, Meera tatkal Rs 800, Isha's grandfather senior Rs 800) in a list and print them and the total collected. (Rates are invented for practice.)

Show solution
C#
List<Ticket> tickets =
[
    new Ticket("Kabir", 800m),
    new TatkalTicket("Meera", 800m),
    new SeniorTicket("Mr Verma", 800m)
];

foreach (Ticket t in tickets)
{
    Console.WriteLine(t);
}
Console.WriteLine($"Total: Rs {tickets.Sum(t => t.Fare())}");

class Ticket(string passenger, decimal baseFare)
{
    public string Passenger { get; } = passenger;
    public decimal BaseFare { get; } = baseFare;

    public virtual decimal Fare() => BaseFare;

    public override string ToString() => $"{GetType().Name}: {Passenger}, Rs {Fare():F2}";
}

class TatkalTicket(string passenger, decimal baseFare) : Ticket(passenger, baseFare)
{
    public override decimal Fare() => BaseFare * 1.30m;
}

class SeniorTicket(string passenger, decimal baseFare) : Ticket(passenger, baseFare)
{
    public override decimal Fare() => BaseFare * 0.60m;
}
Output
Ticket: Kabir, Rs 800.00
TatkalTicket: Meera, Rs 1040.00
SeniorTicket: Mr Verma, Rs 480.00
Total: Rs 2320.00

Primary constructors pass their parameters straight to the base class with : Ticket(passenger, baseFare). ToString lives only in the base class, but it calls the overridden Fare(), so each ticket shows its own price. The total adds 800 + 1040.00 + 480.00.

Quick Quiz

1. What does virtual on a base class method allow?

2. Employee e = new Manager(...); e.MonthlyPay(); runs which version if Manager overrides it?

3. Which constructor runs first when you create a derived object?

4. What does x as Manager return if x is not a Manager?

Learn C# with a mentor

Free lessons get you started. Our Programming & Software Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call