C# tutorials > Core C# Fundamentals > Object-Oriented Programming (OOP) > What is polymorphism in C#, and how is it achieved (inheritance, interfaces, generics)?

What is polymorphism in C#, and how is it achieved (inheritance, interfaces, generics)?

Polymorphism, one of the core principles of Object-Oriented Programming (OOP), allows objects of different classes to be treated as objects of a common type. In C#, polymorphism enables you to write code that can work with objects of different types in a uniform manner, making your code more flexible, maintainable, and extensible. This tutorial will delve into how polymorphism is achieved in C# through inheritance, interfaces, and generics.

Understanding Polymorphism

Polymorphism literally means 'many forms.' In the context of OOP, it refers to the ability of an object to take on many forms. This allows you to write code that operates on a base class or interface, without needing to know the specific type of the object at compile time. This leads to more flexible and reusable code. There are two main types of polymorphism: compile-time (static) and runtime (dynamic) polymorphism.

Polymorphism through Inheritance (Runtime Polymorphism)

Inheritance is a powerful mechanism for achieving polymorphism. By deriving classes from a base class, you can override virtual methods in the base class to provide specific implementations for each derived class. The virtual keyword in the base class allows derived classes to override the method using the override keyword. At runtime, the correct version of the method is called based on the actual type of the object, even when accessed through a base class reference.

public class Animal
{
    public virtual string MakeSound()
    {
        return "Generic animal sound";
    }
}

public class Dog : Animal
{
    public override string MakeSound()
    {
        return "Woof!";
    }
}

public class Cat : Animal
{
    public override string MakeSound()
    {
        return "Meow!";
    }
}

public class Program
{
    public static void Main(string[] args)
    {
        Animal animal1 = new Animal();
        Animal animal2 = new Dog();
        Animal animal3 = new Cat();

        Console.WriteLine(animal1.MakeSound()); // Output: Generic animal sound
        Console.WriteLine(animal2.MakeSound()); // Output: Woof!
        Console.WriteLine(animal3.MakeSound()); // Output: Meow!
    }
}

Concepts Behind the Snippet (Inheritance)

The code demonstrates inheritance polymorphism. The Animal class defines a virtual method MakeSound(). The Dog and Cat classes inherit from Animal and override the MakeSound() method to provide their specific sounds. When you create instances of Dog and Cat and assign them to Animal type variables, the correct MakeSound() method is called at runtime, based on the actual type of the object.

Polymorphism through Interfaces

Interfaces provide another way to achieve polymorphism. An interface defines a contract that classes can implement. Any class that implements an interface must provide an implementation for all the members defined in the interface. This allows you to treat objects of different classes that implement the same interface in a uniform way.

public interface ISpeakable
{
    string Speak();
}

public class Bird : ISpeakable
{
    public string Speak()
    {
        return "Chirp!";
    }
}

public class Person : ISpeakable
{
    public string Speak()
    {
        return "Hello!";
    }
}

public class Program
{
    public static void Main(string[] args)
    {
        ISpeakable speaker1 = new Bird();
        ISpeakable speaker2 = new Person();

        Console.WriteLine(speaker1.Speak()); // Output: Chirp!
        Console.WriteLine(speaker2.Speak()); // Output: Hello!
    }
}

Concepts Behind the Snippet (Interfaces)

The ISpeakable interface defines a Speak() method. The Bird and Person classes implement the ISpeakable interface and provide their own implementations of the Speak() method. This allows you to treat Bird and Person objects as ISpeakable objects and call the Speak() method without knowing their specific types.

Polymorphism through Generics (Compile-time Polymorphism)

Generics allow you to write code that can work with different types without having to write separate code for each type. This is achieved by using type parameters, which are placeholders for the actual types that will be used when the code is instantiated. Generics provide compile-time type safety, ensuring that the correct types are used throughout the code.

public class DataStore<T>
{
    private T _data;

    public DataStore(T initialData)
    {
        _data = initialData;
    }

    public T GetData()
    {
        return _data;
    }

    public void SetData(T newData)
    {
        _data = newData;
    }
}

public class Program
{
    public static void Main(string[] args)
    {
        DataStore<int> intDataStore = new DataStore<int>(10);
        DataStore<string> stringDataStore = new DataStore<string>("Hello");

        Console.WriteLine(intDataStore.GetData()); // Output: 10
        Console.WriteLine(stringDataStore.GetData()); // Output: Hello
    }
}

Concepts Behind the Snippet (Generics)

The DataStore class is a generic class that can store data of any type. The T is a type parameter that represents the type of data that will be stored. When you create an instance of DataStore, you specify the actual type that will be used. In the example, we create a DataStore to store integers and a DataStore to store strings. This allows you to reuse the same DataStore class for different types of data.

Real-Life Use Case: Drawing Shapes

Consider a drawing application. You might have different shapes like circles, rectangles, and triangles. You can define a base class Shape with abstract methods like Area() and Draw(). Each derived class (Circle, Rectangle) would then provide its own implementation for these methods. You can then store these shapes in a list of Shape objects and iterate through them, calling the Draw() and Area() methods without needing to know the specific type of each shape. This demonstrates the power of polymorphism in handling different types of objects in a uniform way.

public abstract class Shape
{
    public abstract double Area();
    public abstract void Draw();
}

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

    public override double Area()
    {
        return Math.PI * Radius * Radius;
    }

    public override void Draw()
    {
        Console.WriteLine("Drawing a circle.");
    }
}

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

    public override double Area()
    {
        return Width * Height;
    }

    public override void Draw()
    {
        Console.WriteLine("Drawing a rectangle.");
    }
}

public class Program
{
    public static void Main(string[] args)
    {
        List<Shape> shapes = new List<Shape>
        {
            new Circle { Radius = 5 },
            new Rectangle { Width = 4, Height = 6 }
        };

        foreach (Shape shape in shapes)
        {
            shape.Draw();
            Console.WriteLine("Area: " + shape.Area());
        }
    }
}

Best Practices

  • Favor composition over inheritance: While inheritance is a powerful tool, overuse can lead to fragile base class problems. Consider using interfaces and composition to achieve polymorphism in a more flexible way.
  • Design interfaces carefully: When designing interfaces, ensure they are cohesive and represent a clear contract. Avoid creating overly complex interfaces.
  • Use generics for type safety: Generics provide compile-time type safety, which can help prevent runtime errors. Use generics whenever possible to improve the robustness of your code.
  • Abstract away common behaviour: Look for opportunities to abstract away common behavior into base classes or interfaces to reduce code duplication and improve maintainability.

Interview Tip

When discussing polymorphism in an interview, be prepared to explain the different types of polymorphism (runtime and compile-time) and provide examples of how it is achieved through inheritance, interfaces, and generics. Also, be ready to discuss the benefits and drawbacks of each approach. Demonstrate your understanding of the SOLID principles, particularly the Liskov Substitution Principle, which is closely related to polymorphism.

When to Use Polymorphism

Use polymorphism when:

  • You need to work with objects of different types in a uniform manner.
  • You want to create extensible and maintainable code.
  • You want to reduce code duplication.
  • You want to adhere to the open/closed principle (classes should be open for extension but closed for modification).

Memory Footprint

The memory footprint of polymorphism can vary depending on the implementation. Inheritance might add a small overhead due to the vtable (virtual function table) used for dynamic dispatch. Interfaces have a minimal memory footprint. Generics generally don't introduce a significant memory overhead because type information is resolved at compile time.

Alternatives

Alternatives to polymorphism include:

  • Conditional statements: Using if or switch statements to handle different types of objects. This approach can become unwieldy and difficult to maintain as the number of types increases.
  • Code duplication: Writing separate code for each type of object. This leads to code duplication and makes the code harder to maintain.
Polymorphism offers a much cleaner and more maintainable solution compared to these alternatives.

Pros of Polymorphism

  • Code reusability: Polymorphism promotes code reusability by allowing you to write code that can work with objects of different types.
  • Extensibility: Polymorphism makes it easy to extend your code by adding new types of objects without modifying existing code.
  • Maintainability: Polymorphism makes your code easier to maintain by reducing code duplication and improving code organization.
  • Flexibility: Polymorphism provides flexibility by allowing you to change the behavior of your code at runtime.

Cons of Polymorphism

  • Complexity: Polymorphism can add complexity to your code, especially when using inheritance.
  • Performance: Dynamic dispatch (used in runtime polymorphism) can have a slight performance overhead compared to static dispatch.

FAQ

  • What is the difference between compile-time and runtime polymorphism?

    Compile-time polymorphism (also known as static polymorphism) is resolved at compile time, typically through method overloading or generics. Runtime polymorphism (also known as dynamic polymorphism) is resolved at runtime, typically through inheritance and virtual methods. The correct method to call is determined based on the actual type of the object at runtime.

  • What is the Liskov Substitution Principle?

    The Liskov Substitution Principle states that subtypes must be substitutable for their base types without altering the correctness of the program. In other words, if you have a function that operates on a base class, it should also work correctly when you pass in an instance of a derived class.

  • When should I use inheritance vs. interfaces for polymorphism?

    Use inheritance when you have a clear 'is-a' relationship between classes and you want to share some common implementation. Use interfaces when you want to define a contract that multiple unrelated classes can implement, and you don't want to share any implementation details.