On this page

Inheritance and interfaces in C#

14 min read TextCh. 2 — OOP in C#

Inheritance in C#

Inheritance lets you create new classes based on existing ones, reusing and extending their functionality. In C#, a class can inherit from only one class (single inheritance) but can implement multiple interfaces.

// Inheritance syntax: ChildClass : ParentClass
public class Animal
{
    public string Name { get; init; } = string.Empty;
    public string Sound()  => "...";
    public virtual string Describe() => $"{Name} makes '{Sound()}'";
}

public class Dog : Animal
{
    public new string Sound() => "Woof";  // hides (not overrides)
}

public class Cat : Animal
{
    public override string Describe()    // overrides the virtual
        => base.Describe() + " [is a cat]";
}

virtual, override, and abstract

Keyword Meaning
virtual The method can be overridden in subclasses
override Overrides a virtual or abstract method from the base class
abstract The method must be implemented in subclasses (class must be abstract)
sealed Prevents the method from being overridden in further subclasses
new Hides the base class member (different from override)
public abstract class Shape
{
    // Abstract: NO implementation, MUST be implemented
    public abstract double Area();
    public abstract double Perimeter();

    // Virtual: WITH implementation, MAY be overridden
    public virtual void Draw()
        => Console.WriteLine($"Drawing {GetType().Name}...");

    // Concrete: cannot be overridden unless marked virtual
    public string TypeName() => GetType().Name;
}

public class Circle : Shape
{
    public double Radius { get; init; }

    public override double Area()       => Math.PI * Radius * Radius;
    public override double Perimeter()  => 2 * Math.PI * Radius;

    // Override and then seal — no subclass can override this further
    public sealed override void Draw()
        => Console.WriteLine($"○ Circle with radius {Radius:F2}");
}

public class Rectangle : Shape
{
    public double Width  { get; init; }
    public double Height { get; init; }

    public override double Area()       => Width * Height;
    public override double Perimeter()  => 2 * (Width + Height);
}

Polymorphism

Polymorphism allows treating objects of different types uniformly through their base type or interface:

List<Shape> shapes = new()
{
    new Circle    { Radius = 5 },
    new Rectangle { Width = 4, Height = 6 },
    new Circle    { Radius = 3 },
};

double totalArea = 0;
foreach (Shape s in shapes)
{
    s.Draw();                          // specific overridden method
    totalArea += s.Area();             // polymorphism in action
    Console.WriteLine($"  Area: {s.Area():F2}");
}
Console.WriteLine($"Total area: {totalArea:F2}");

Interfaces

An interface defines a contract without implementation (except for C# 8+ default methods). Any type implementing the interface guarantees it has those members:

public interface IRepository<T> where T : class
{
    Task<T?>       GetByIdAsync(int id);
    Task<List<T>>  ListAsync();
    Task<T>        CreateAsync(T entity);
    Task<T>        UpdateAsync(T entity);
    Task<bool>     DeleteAsync(int id);
}

public interface IValidatable
{
    bool IsValid();
    IEnumerable<string> GetErrors();
}

Multiple interface implementation

public class Employee : IRepository<Employee>, IValidatable, IComparable<Employee>
{
    public int     Id     { get; init; }
    public string  Name   { get; set; } = string.Empty;
    public decimal Salary { get; set; }

    // IValidatable
    public bool IsValid()
        => !string.IsNullOrEmpty(Name) && Salary > 0;

    public IEnumerable<string> GetErrors()
    {
        if (string.IsNullOrEmpty(Name)) yield return "Name is required";
        if (Salary <= 0) yield return "Salary must be positive";
    }

    // IComparable<Employee>
    public int CompareTo(Employee? other)
        => other is null ? 1 : Name.CompareTo(other.Name);

    // IRepository<Employee> — simplified implementation
    public Task<Employee?> GetByIdAsync(int id) => Task.FromResult<Employee?>(null);
    public Task<List<Employee>> ListAsync() => Task.FromResult(new List<Employee>());
    public Task<Employee> CreateAsync(Employee e) => Task.FromResult(e);
    public Task<Employee> UpdateAsync(Employee e) => Task.FromResult(e);
    public Task<bool> DeleteAsync(int id) => Task.FromResult(true);
}

Default interface methods (C# 8+)

public interface ILogger
{
    void Log(string message, string level);

    // Default method implementations
    void LogInfo(string message)    => Log(message, "INFO");
    void LogWarning(string message) => Log(message, "WARNING");
    void LogError(string message)   => Log(message, "ERROR");
}

public class ConsoleLogger : ILogger
{
    // Only the abstract method needs implementation
    public void Log(string message, string level)
        => Console.WriteLine($"[{level}] {DateTime.Now:HH:mm:ss}{message}");
}

ILogger logger = new ConsoleLogger();
logger.LogInfo("Application started");   // uses default
logger.LogError("System error");         // uses default
logger.Log("Custom message", "DEBUG");   // direct implementation

Liskov Substitution Principle

A subclass instance must be able to substitute its base class without breaking the program:

// BAD: violates Liskov — a square is not correctly a rectangle
public class BadRectangle
{
    public virtual int Width  { get; set; }
    public virtual int Height { get; set; }
    public int Area() => Width * Height;
}

public class BadSquare : BadRectangle
{
    public override int Width  { set => base.Width = base.Height = value; }
    public override int Height { set => base.Width = base.Height = value; }
}

// GOOD: model reality with interfaces
public interface IShape      { double Area(); }
public class GoodRectangle  : IShape { public double Area() => Width * Height; public double Width; public double Height; }
public class GoodSquare     : IShape { public double Area() => Side * Side;    public double Side; }

Practice

  1. Shape hierarchy: Create abstract class Shape with Area() and Perimeter(). Implement Circle, Rectangle, and Triangle. Create a list of shapes and calculate the total area.
  2. IExportable interface: Define an IExportable interface with ExportCsv() and ExportJson() methods. Implement it in a Report class.
  3. Polymorphism: Create a PrintShapes(IEnumerable<Shape> shapes) method that iterates and prints the type, area, and perimeter of each shape.

In the next lesson we will explore generics and collections: List, Dictionary<K,V>, HashSet, and how to create your own generic types.

Prefer composition over inheritance
Inheritance creates tight coupling. In many cases it is better to inject interfaces (composition) than to inherit. Use inheritance when the relationship is genuinely 'is-a' (an ElectricCar IS a Vehicle). For reusable behavior, prefer interfaces.
Interfaces vs abstract classes
Use interfaces when you want to define a contract that multiple unrelated types can implement. Use abstract classes when you share base implementation and there is a clear hierarchy. In C# you can implement multiple interfaces but only inherit from one class.
Do not abuse deep inheritance
Inheritance hierarchies deeper than 2-3 levels become hard to maintain. The Liskov Substitution Principle says a subclass should be substitutable for its base class without breaking the expected behavior.
// Abstract base class
public abstract class Vehicle
{
    public string Make  { get; init; }
    public string Model { get; init; }
    public int    Year  { get; init; }

    protected Vehicle(string make, string model, int year)
    {
        Make  = make;
        Model = model;
        Year  = year;
    }

    // Abstract method — MUST be implemented in subclasses
    public abstract string EngineType();

    // Virtual method — MAY be overridden
    public virtual string Describe()
        => $"{Make} {Model} ({Year}) — {EngineType()}";

    // Non-virtual concrete method
    public string Identifier() => $"{Make}-{Model}-{Year}";
}

// Concrete subclass
public class ElectricCar : Vehicle
{
    public int BatteryCapacity { get; init; } // kWh

    public ElectricCar(string make, string model, int year, int battery)
        : base(make, model, year)
    {
        BatteryCapacity = battery;
    }

    public override string EngineType() => "Electric";

    // sealed — no subclass can override this further
    public sealed override string Describe()
        => base.Describe() + $" | Battery: {BatteryCapacity} kWh";
}

var tesla = new ElectricCar("Tesla", "Model 3", 2024, 82);
Console.WriteLine(tesla.Describe());
// Tesla Model 3 (2024) — Electric | Battery: 82 kWh