C# Story

Chapter #6 – Class Relationships

inheritance, interfaces, abstract classes, composition, polymorphism

6.0 Prologue

Classes rarely stand alone. This chapter covers the relationships between classes: how one class inherits from another, how interfaces define shared contracts across unrelated types, and how composition and polymorphism shape extensible designs.

6.1 Inheritance

C# supports single implementation inheritance: a class derives from at most one base class. All classes ultimately derive from System.Object. public class Animal { public string Name { get; } public Animal(string name) => Name = name; public virtual string Sound() => "..."; public override string ToString() => $"{Name} says {Sound()}"; } public class Dog : Animal { public Dog(string name) : base(name) { } public override string Sound() => "Woof"; } public class Cat : Animal { public Cat(string name) : base(name) { } public override string Sound() => "Meow"; } A sealed class cannot be derived from. A sealed override prevents further overriding in deeper derived classes. Use the new modifier to intentionally hide (not override) a base member. Hiding breaks runtime polymorphism for the hidden member.

6.2 Abstract Classes

An abstract class cannot be instantiated directly and may contain abstract members (signature only, no body). Derived classes must implement all abstract members: public abstract class Shape { public abstract double Area { get; } public abstract double Perimeter { get; } public void Print() => // concrete shared behavior Console.WriteLine($"A={Area:F2} P={Perimeter:F2}"); } public class Rectangle : Shape { public double W { get; } public double H { get; } public Rectangle(double w, double h) { W = w; H = h; } public override double Area => W * H; public override double Perimeter => 2 * (W + H); } Use abstract classes when you want to share implementation code among related types. Use interfaces when you want to define a contract that unrelated types can satisfy.

6.3 Interface Implementation

A class or struct may implement any number of interfaces simultaneously. Interface members are public by default. Explicit implementation hides a member from the type's public surface and exposes it only through the interface type: public interface IReadable { string Read(); } public interface IWritable { void Write(string data); } public class File : IReadable, IWritable { private string _content = ""; public string Read() => _content; public void Write(string data) => _content += data; } // Explicit implementation hides method unless cast to interface public class SecureFile : IReadable { private string _data = "secret"; string IReadable.Read() => _data; // only accessible via IReadable ref }

6.4 Composition

Composition (a “has-a” relationship) is often preferable to inheritance. Instead of deriving a Car from Engine, a Car holds an Engine: public class Engine { public int Horsepower { get; } public Engine(int hp) => Horsepower = hp; public void Start() => Console.WriteLine("Engine started"); } public class Car { private readonly Engine _engine; public string Model { get; } public Car(string model, int hp) { Model = model; _engine = new Engine(hp); } public void Drive() { _engine.Start(); Console.WriteLine($"{Model} moving"); } } Prefer composition over inheritance when the relationship is not truly “is-a”, when you need to vary the component at runtime, or when deep inheritance hierarchies become fragile and hard to reason about.

6.5 Polymorphism

Runtime polymorphism in C# works through virtual dispatch. When a method is called on a base-class or interface reference, the CLR dispatches to the most-derived override: List<Animal> animals = new() { new Dog("Rex"), new Cat("Whiskers"), new Dog("Buddy") }; foreach (var a in animals) Console.WriteLine(a); // calls each override of Sound() Interface polymorphism allows unrelated types to be treated uniformly: IEnumerable<IShape> shapes = new IShape[] { new Circle(3), new Rectangle(4, 5) }; double total = shapes.Sum(s => s.Area); Pattern matching (C# 7+) provides a type-safe alternative to downcasts. The switch expression dispatches on the runtime type and can simultaneously deconstruct and test properties: string Describe(Shape s) => s switch { Circle c when c.Radius > 10 => "large circle", Circle c => "small circle", Rectangle { W: var w, H: var h } when w == h => "square", Rectangle => "rectangle", _ => "unknown shape" };

6.6 Covariance and Contravariance

Generic interfaces and delegates support variance declarations:
  • out Tcovariant: IEnumerable<Dog> can be used where IEnumerable<Animal> is expected
  • in Tcontravariant: Action<Animal> can be used where Action<Dog> is expected
These variance annotations let you write more flexible APIs without explicit casting and without sacrificing type safety.

6.7 Epilogue

This chapter covered the relationships between C# classes: inheritance, abstract classes, interface implementation, composition, runtime polymorphism, pattern matching, and variance. The next chapter dives into generics.

6.8 References

Inheritance — Microsoft docs
Interfaces — Microsoft docs
Pattern matching — Microsoft docs
Covariance & contravariance