CSharpStory_ClassRelationships.html
copyright © James Fawcett
Revised: 04/26/2026
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 T — covariant: IEnumerable<Dog>
can be used where IEnumerable<Animal> is expected
- in T — contravariant: 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