Skip to main content

Inheritance, Interfaces, and Abstract Classes

Inheritance

One of the principles of OOP is inheritance. It is a simple method to extend your already written classes with additional functionalities or to generalize functionalities for multiple classes. When we use inheritance, we say that a child/derived class extends, derives, or inherits from a parent/base class, and then a child class is always of the type of the parent class.

/* This is a base class, which we will use to demonstrate how it can be extended
* and how methods can be overridden.
*/
public class Animal
{
public string Name { get; init; }

public Animal(string name)
{
Name = name;
}

/* Virtual methods are methods that have a default implementation
* but can be overridden by a child class.
*/
public virtual string MakeSound() => $"{Name} makes a sound!";

/* Non-virtual methods are also present for the derived class
* but they cannot be overridden; they can only be hidden (shadowed) by derived classes.
*/
public void Eats() => Console.WriteLine("{0} is eating...", Name);
}

// A class derives from another using ":" followed by the name of the inherited class.
public class Duck : Animal
{
/* The constructor of the derived class must call a constructor of the parent class.
* By default, that constructor is the parameterless one; otherwise, it must be called using the "base" keyword as shown below.
*/
public Duck(string name) : base(name)
{
}

/* Here, we override the virtual method with a new implementation using "override".
* We can also call the parent class's method using the "base" keyword.
*/
public override string MakeSound() => $"{base.MakeSound()} Quacks!";
// We can add new functionalities to the derived class compared to the inherited class.
public void Fly() => Console.WriteLine("{0} is flying...", Name);
}

public class Dog : Animal
{
public Dog(string name) : base(name)
{
}

// Here, we override the virtual method with a new implementation using "override".
public override string MakeSound() => $"{Name} barks!";
/* Here, we hide the parent's method using "new".
* The new implementation is used only when the instance is of the derived class type.
*/
public new void Eats() => Console.WriteLine("{0} is eating meat...", Name);
// We can add new functionalities to the derived class compared to the inherited class.
public void Run() => Console.WriteLine("{0} is running...", Name);
}

// Each object that inherits from Animal is of this type and can be implicitly cast.
Animal obj1 = new Animal("Dave");
Animal obj2 = new Dog("Richard");
Animal obj3 = new Duck("James");

Animal[] animals = { obj1, obj2, obj3 };

foreach (var animal in animals)
{
// We can see that we can use methods of the parent class, including the virtual ones because they have an implementation.
Console.WriteLine("Testing methods for {0} as parent class.", animal.Name);
animal.Eats();
Console.WriteLine(animal.MakeSound());

/* Here, we can treat each instance based on its most specific type.
* We can observe the difference in behavior for the Eats() method, which is shadowed in the derived class.
*/
Console.WriteLine("Testing methods for {0} as actual class.", animal.Name);
switch (animal)
{
case Dog dog:
dog.Eats();
Console.WriteLine(dog.MakeSound());
dog.Run();
break;
case Duck duck:
duck.Eats();
Console.WriteLine(duck.MakeSound());
duck.Fly();
break;
default:
animal.Eats();
Console.WriteLine(animal.MakeSound());
break;
}
}

Inheritance Concepts

As seen, there are two concepts here concerning method inheritance (applicable only to non-static methods; static methods cannot be overridden): overriding and shadowing.

In overriding, a method that exists in the parent class as virtual is overridden by the child class using the override keyword. The virtual method has an implementation, but it's only the default one. A child class is free to override the method or not. At runtime, regardless of whether the instance is declared as the parent or child class, the overridden method of the instance is considered.

In shadowing, a child class provides a new implementation of a non-virtual method using the new keyword. This shadows or hides the parent class's method if the instance is declared as the derived class. However, if the instance is declared as the parent class, then the parent's method is called, not the child's. Usually, shadowing is used only in extreme cases when there is no other solution for a particular scenario.

Interfaces

Note that a class can directly inherit from only one class, not multiple classes, even though the inherited class can itself have a parent. By default, every class inherits from the object class in C#. The reason multiple direct inheritance is not allowed is that it would be difficult for the compiler or programmer to determine which field or method is being referred to if multiple parent classes have some definitions of fields and methods that are the same. An example of this issue is the diamond problem.

C#

Let's assume classes B and C inherit from class A, and class D inherits from classes B and C. If this were allowed and class A has fields or methods that are overridden, it would be unclear which field or method should be used, leading to errors. This is a reason why only a single inheritance is possible, similar to other languages like Java. The mentioned issue is called the diamond problem, a specific case of a problem that can arise in multiple inheritance.

As an example, let's say you want to process data from various objects, each of a different type, but the result is of the same type. For instance, you have a list of geometric shapes, and you want to calculate the area. You think of having a class for each shape with the .ComputeArea() method, but each object type calculates the area differently.

The solution is to use interfaces. An interface is a type that has no implementation and cannot be directly instantiated, but it specifies which methods and properties a type must have. This way, you can have multiple objects with the same method but different implementations. Methods declared in interfaces are called abstract methods or purely virtual methods in the literature. Classes no longer extend interfaces; here, it's said that classes implement interfaces.

An important point to note is that you can have objects declared as the interface type, but interfaces can never be instantiated—only classes that implement interfaces can be instantiated. A class that implements an interface is obligated to have implemented all the methods from that interface.

// Declare an interface like a class, but replace "class" with "interface".
public interface IShape
{
/* We can declare method definitions without implementation.
* Methods here, because they lack implementation, are abstract or purely virtual.
*/
public double ComputeArea();
// Similarly, we can also have readonly properties in interfaces.
public string Name { get; }
}

// Declaring the implementation of an interface is done the same way as inheriting from a class.
public class Circle : IShape
{
public double Radius { get; init; }

public Circle(double radius) => Radius = radius;

public double ComputeArea() => Math.PI * Radius * Radius;
public string Name => nameof(Circle);
}

public class Rectangle : IShape
{
public double Width { get; }
public double Height { get; }

public Rectangle(double width, double height)
{
Width = width;
Height = height;
}

public double ComputeArea() => Height * Width;
public string Name => nameof(Rectangle);
}

public class Triangle : IShape
{
public double SideA { get; }
public double SideB { get; }
public double SideC { get; }

public Triangle(double sideA, double sideB, double sideC)
{
SideA = sideA;
SideB = sideB;
SideC = sideC;
}

public double ComputeArea()
{
var s = (SideA + SideB + SideC) / 2;

return Math.Sqrt(s * (s - SideA) * (s - SideB) * (s - SideC));
}

public string Name => nameof(Triangle);
}

// Every shape that implements the IShape interface is of this type.
IShape[] shapes = { new Circle(10), new Rectangle(7, 8), new Triangle(3, 4, 5) };

Console.WriteLine("The computed areas are: ");

foreach (var shape in shapes)
{
Console.WriteLine("{0} {1}", shape.Name, shape.ComputeArea());
}

Through interfaces, we can abstract away implementation details and utilize the interface to perform computations in a generic manner. Since interfaces lack implementation, a class can extend multiple interfaces in addition to a class.

Abstract Classes

In addition to interfaces, we can also have abstract classes. These can only be inherited by another class, much like regular classes, with the distinction that abstract classes cannot be instantiated, unlike interfaces. Abstract classes are useful when you have generic logic, but certain implementation details depend on specific cases. In this regard, specific cases can be represented by abstract methods and properties that are implemented by derived classes. The generic logic is then encapsulated within methods and properties implemented in the abstract class, which are shared by the derived classes to reduce the amount of code duplication.

Below is an example of using abstract classes to implement two different data storage solutions.

// Declare the interface to be used by the factory.
public interface IStorage
{
public void SaveValue(string key, string value);
public string? GetValue(string key);
public void AddValue(string key, string value);
}

/* An abstract class can or cannot implement an interface.
* Here, we use an abstract class because a method can be implemented from other methods
* and to reduce code, we implement that method.
*/
public abstract class AbstractStorage : IStorage
{
// Methods that are not implemented are declared as "abstract".
public abstract void SaveValue(string key, string value);
public abstract string? GetValue(string key);

/* An abstract class can have implemented methods. If implementing an interface,
* the methods in the interface must either be declared as abstract or implemented.
*/
public void AddValue(string key, string value)
{
var oldValue = GetValue(key);

SaveValue(key, oldValue != null ? $"{oldValue} {value}" : value);
}
}

// If we use the "sealed" keyword, this class can no longer be further derived.
public sealed class FileStorage : AbstractStorage
{
private const string FilePath = "./Storage.txt";

// The "override" keyword must be used on abstract methods from the abstract class.
public override void SaveValue(string key, string value)
{
// ...
}

public override string? GetValue(string key)
{
// ...
}
}

public class InMemoryStorage : AbstractStorage
{
private readonly Dictionary<string, string> _cache = new();

public override void SaveValue(string key, string value) => _cache[key] = value;

public override string? GetValue(string key) => _cache.TryGetValue(key, out var value) ? value : default;
}

An application for interfaces is the implementation of a design pattern called the factory pattern. A factory is a class that instantiates multiple types on demand. This is a simple example of how a factory can be implemented. Of course, in practice, this factory can be configured beforehand to parameterize the instance creation.

// With this enum, we can request the factory to produce instances of IStorage.
public enum StorageTypeEnum
{
File,
InMemory
}

// We can create a class that functions as a factory for creating instances of interfaces.
public static class StorageFactory
{
// We have a method that, upon request, provides us with different instances by providing a value that specifies the instance type.
public static IStorage GetStorage(StorageTypeEnum type) =>
type switch
{
StorageTypeEnum.File => new FileStorage(),
StorageTypeEnum.InMemory => new InMemoryStorage(),
_ => throw new ArgumentOutOfRangeException(nameof(type), type, "Cannot produce this type!")
};
}

Interfete vs. Clase abstracte vs. Clase concrete

  • Use interfaces when you need only a list of methods and properties for multiple types.
  • Use abstract classes when you have common logic for all derived types and want to implement it once.
  • Do not use abstract classes as interfaces; there are considerable differences in how they are perceived by the runtime. Interfaces can also serve as identifiers for various libraries.
  • In abstract classes, you can declare and implement default behavior with the "virtual" keyword. This behavior can be overridden in derived classes using the "override" keyword if necessary. Use these when dealing with such cases instead of interfaces.
  • In almost all cases, you'll need either interfaces or abstract classes for inheritance/extensibility. Only derive from concrete classes if absolutely necessary and you need to add functionality over the inherited class. It's preferable to use static polymorphism, partial classes, or extension methods in these cases.
  • Use regular classes for inheritance if you intend to extend objects with more data and want to minimize code duplication.
caution

It's important to mention that in many cases, inheritance/extensibility can be avoided, and it would be preferable to use other methods like generics. However, this depends on the case and specific requirements.