Understanding C# Inheritance
c# inheritance: Master C# inheritance fundamentals: syntax, base class constructors, virtual and override, sealed and abstract classes, and practical design tradeoffs.
c# inheritance requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.
In C#, inheritance establishes an "is-a" relationship between a base (parent) class and a derived (child) class. The derived class inherits accessible members from the base class, allowing code reuse and enabling polymorphic dispatch. This article focuses on the core mechanics you need to apply inheritance correctly in real-world code, including constructor chaining, member overriding, and the design decisions that affect maintainability.
Declaration and Base Class Access
A derived class is declared with a colon followed by the base class name. The derived class implicitly receives all public and protected members of the base class. Private members are not inherited, but they still exist inside the base class methods that the derived class calls.
public class Animal { public string Name { get; set; } public void Eat() { Console.WriteLine($"{Name} is eating."); } } public class Dog : Animal { public void Bark() { Console.WriteLine($"{Name} is barking."); } }
Here, Dog inherits Name and Eat() from Animal, and adds its own Bark() method. The Dog class can be used wherever an Animal is expected, which is the essence of polymorphism.
Base Class Constructors and Initialization
When a derived class is instantiated, the base class constructor runs first. If the base class has only a parameterless constructor, the derived class constructor implicitly calls it. If the base class has no parameterless constructor, the derived class must explicitly call one of its base constructors using the base keyword.
public class Vehicle { public string Make { get; } public string Model { get; } public Vehicle(string make, string model) { Make = make; Model = model; } } public class Car : Vehicle { public int Doors { get; } public Car(string make, string model, int doors) : base(make, model) { Doors = doors; } }
If you omit the : base(...) call, the compiler looks for a parameterless constructor in Vehicle. Since none exists, the code fails to compile. This forces derived classes to explicitly initialize the base state, which prevents inconsistent objects.
Overriding Virtual Members
Inheritance allows derived classes to replace the implementation of a base class method or property. The base member must be marked virtual, and the derived member uses override. This enables polymorphic behavior: the derived version is called when the object is accessed through a base reference.
public class Shape { public virtual double Area() { return 0; } } public class Circle : Shape { public double Radius { get; set; } public override double Area() { return Math.PI * Radius * Radius; } }
When you call Area() on a Shape variable that holds a Circle, the Circle implementation runs. If the derived class does not override the method, the base implementation is used. Marking a member virtual is an explicit design decision: it opens the member for extension in subclasses, which is not always desirable.
Preventing Overriding with sealed
You can stop further overriding by marking an override as sealed. This is useful when you have a precise implementation that subclasses should not alter.
public class SpecialCircle : Circle { public override sealed double Area() { return base.Area(); // same behavior, but no further override allowed } }
A sealed override still allows polymorphism up to the point of the seal. A further derived class cannot override Area() again; it can only inherit the SpecialCircle implementation. This helps preserve invariants when a method's behavior is critical to the class's correctness.
Using the base Keyword Inside Overrides
When overriding a method, you may need to call the base class implementation to extend it rather than replace it entirely. The base keyword serves this purpose and also gives access to base class constructors.
public class EmailSender { public virtual void Send(string message) { // Common logging or audit logic Console.WriteLine($"Preparing to send: {message}"); } } public class SecureEmailSender : EmailSender { public override void Send(string message) { // Additional security check if (!message.Contains("confidential")) { throw new InvalidOperationException("Missing confidentiality label."); } base.Send(message); } }
Calling base.Send() executes the base logic after the derived validation passes. This pattern keeps the base behavior in one place and avoids duplicating it in every subclass. Overusing base can create fragile inheritance chains, so evaluate whether overriding with a full implementation is simpler.
Abstract Classes and When to Use Them
An abstract class cannot be instantiated. It may contain abstract members, which have no implementation and must be overridden in concrete derived classes. Abstract classes are useful when you want to enforce a contract while sharing common implementation.
public abstract class Report { public void Generate() { var data = FetchData(); var content = Format(data); Save(content); } protected abstract object FetchData(); protected abstract string Format(object data); private void Save(string content) { Console.WriteLine(content); } }
Generate() provides the template algorithm, while the abstract steps FetchData() and Format() force subclasses to supply the details. Abstract classes are a deeper form of inheritance than simple virtual methods. Use them when you need to share non-public implementation details; prefer interfaces when you only need to enforce a public contract.
Inheritance and Object Hierarchy Depth
Deep inheritance trees are often a sign of overdesign. Each additional level multiplies the complexity of understanding what a method actually does, especially when virtual calls chain through several layers. Consider the classic problem: a Person class, a Student : Person, and GraduateStudent : Student. If each level overrides a method and calls base, tracing the flow becomes difficult.
A practical guideline is to keep inheritance depth to two or three levels. When you find yourself adding a level solely to reuse a method, consider composition instead. Delegating behavior to contained objects often leads to more testable and modifiable code.
Inheritance vs. Composition: A Practical Comparison
The decision between inheritance and composition comes down to whether the "is-a" relationship holds and whether you need polymorphic substitution. Use inheritance when you want to treat derived objects as the base type, for example in a collection of Shape objects that you call Area() on. Use composition when a class simply needs functionality from another class.
For instance, a Car is not a Engine; it has an Engine. Modeling this with inheritance would be wrong. Instead, Car contains an Engine instance and delegates to it. Composition keeps the responsibilities separate and avoids forcing unrelated behavior into the inheritance chain.
Runtime Type Checking and Casting
Inheritance changes the runtime type of objects. You can check the actual type using is and do a safe cast with as.
object item = new Dog(); if (item is Dog dog) { dog.Bark(); }
The pattern-matching syntax avoids an explicit check followed by a cast. Avoid excessive type checks: if you are checking the concrete type everywhere, polymorphism is not being used effectively. Prefer defining virtual methods or interfaces that capture the varying behavior.
Sealed Classes for Better API Design
Marking a class sealed prevents it from being used as a base class. This is a strong design signal that the class is complete and should not be extended.
public sealed class ConfigurationManager { // Implementation }
Sealed classes often produce more predictable code: there is no risk of a subclass altering behavior in unexpected ways. They also enable the compiler to resolve method calls more directly, though the performance gain is usually negligible in application code. Use sealed when you have no intention of supporting extension and you want to communicate that clearly.
Handling Constructor Invocation Order and Exceptions
When a derived class constructor calls a base constructor, the base constructor executes before the derived constructor body. If the base constructor throws an exception, the derived constructor never runs. This has implications for resource cleanup: if the base constructor allocates resources and then fails, the derived class has no chance to release them.
For example, if Vehicle's constructor opens a network connection and then throws, Car's constructor is aborted. There is no partial Car object to dispose. In such cases, design base constructors to avoid throwing, or use factory methods with explicit initialization that can be handled in a try-catch.
Maintainability and Compatibility Risks
Inheritance introduces tight coupling: a change in the base class can silently affect all subclasses. Adding a new virtual method might be unexpected in subclasses that have their own method with the same name, causing a hiding warning. Changing a method from non-virtual to virtual in a widely used base class can break external subclasses that have a method with the same signature but without override.
To mitigate these risks, explicitly mark members as virtual only when you intend them to be overridden. Document what the base method does and what subclasses must consider. Avoid calling virtual methods in base constructors: the derived class hasn't finished initializing, so the overridden method may run with incomplete state.
Final Code Example: A Complete Inheritance Scenario
Putting everything together, here is a realistic use of inheritance with constructor chaining, virtual methods, and a sealed override.
public abstract class Payment { public decimal Amount { get; } protected Payment(decimal amount) { Amount = amount; } public abstract void Process(); } public class CreditCardPayment : Payment { public string CardNumber { get; } public CreditCardPayment(decimal amount, string cardNumber) : base(amount) { CardNumber = cardNumber; } public override void Process() { Console.WriteLine($"Charging {Amount:C} to card ending in {CardNumber[^4..]}."); } } public class BankTransferPayment : Payment { public string AccountNumber { get; } public BankTransferPayment(decimal amount, string accountNumber) : base(amount) { AccountNumber = accountNumber; } public override void Process() { Console.WriteLine($"Transferring {Amount:C} to account {AccountNumber}."); } }
This design allows you to process all payments polymorphically:
var payments = new Payment[] { new CreditCardPayment(100m, "1234-5678-9012-3456"), new BankTransferPayment(250m, "DE89370400440532013000") }; foreach (var payment in payments) { payment.Process(); }
The abstract Process method forces each payment type to implement its own logic, while the shared Amount property stays in the base. This is a maintainable pattern that avoids duplication and allows new payment types to be added with minimal changes to existing code.