3.3 - Polymorphism
Polymorphism is one of the four pillars of object-oriented programming. The word "polymorphism" comes from Greek, meaning "many forms." In programming, polymorphism allows objects of different types to be treated as objects of a common base type, enabling a single interface to represent different underlying forms (data types).
3.3.1 - Runtime Polymorphism
Runtime polymorphism, also known as dynamic polymorphism or late binding, occurs when a method is resolved at runtime rather than at compile time. In C#, this is achieved through method overriding.
// Base class
public class Shape
{
public virtual double CalculateArea()
{
return 0;
}
}
// Derived classes
public class Circle : Shape
{
public double Radius { get; set; }
public Circle(double radius)
{
Radius = radius;
}
public override double CalculateArea()
{
return Math.PI * Radius * Radius;
}
}
public class Rectangle : Shape
{
public double Width { get; set; }
public double Height { get; set; }
public Rectangle(double width, double height)
{
Width = width;
Height = height;
}
public override double CalculateArea()
{
return Width * Height;
}
}
Using Runtime Polymorphism
// Creating objects
Shape shape = new Shape();
Circle circle = new Circle(5);
Rectangle rectangle = new Rectangle(4, 6);
// Creating an array of Shape objects
Shape[] shapes = new Shape[] { shape, circle, rectangle };
// Using polymorphism to call the appropriate method for each object
foreach (Shape s in shapes)
{
Console.WriteLine($"Area of {s.GetType().Name}: {s.CalculateArea()}");
}
// Output:
// Area of Shape: 0
// Area of Circle: 78.53981633974483
// Area of Rectangle: 24
In this example, the CalculateArea() method is called on each object in the shapes array. Even though the array is of type Shape[], the appropriate implementation of CalculateArea() is called for each object based on its actual type at runtime.
3.3.2 - Compile-time Polymorphism
Compile-time polymorphism, also known as static polymorphism or early binding, occurs when the method to be called is determined at compile time. In C#, this is achieved through method overloading.
public class Calculator
{
// Method overloading - same method name, different parameter types
public int Add(int a, int b)
{
return a + b;
}
public double Add(double a, double b)
{
return a + b;
}
public string Add(string a, string b)
{
return a + b;
}
// Method overloading - same method name, different number of parameters
public int Add(int a, int b, int c)
{
return a + b + c;
}
// Method overloading - same method name, different parameter order
public double Add(int a, double b)
{
return a + b;
}
public double Add(double a, int b)
{
return a + b;
}
}
Using Compile-time Polymorphism
Calculator calc = new Calculator();
// The compiler determines which method to call based on the argument types
int sum1 = calc.Add(5, 10); // Calls Add(int, int)
double sum2 = calc.Add(5.5, 10.5); // Calls Add(double, double)
string sum3 = calc.Add("Hello, ", "World!"); // Calls Add(string, string)
int sum4 = calc.Add(5, 10, 15); // Calls Add(int, int, int)
double sum5 = calc.Add(5, 10.5); // Calls Add(int, double)
double sum6 = calc.Add(5.5, 10); // Calls Add(double, int)
Console.WriteLine($"Sum1: {sum1}");
Console.WriteLine($"Sum2: {sum2}");
Console.WriteLine($"Sum3: {sum3}");
Console.WriteLine($"Sum4: {sum4}");
Console.WriteLine($"Sum5: {sum5}");
Console.WriteLine($"Sum6: {sum6}");
// Output:
// Sum1: 15
// Sum2: 16
// Sum3: Hello, World!
// Sum4: 30
// Sum5: 15.5
// Sum6: 15.5
3.3.3 - Method Overloading
Method overloading is a form of compile-time polymorphism where multiple methods in the same class have the same name but different parameters. The compiler determines which method to call based on the number, types, and order of the arguments.
Rules for Method Overloading
- Methods must have the same name.
- Methods must have different parameter lists (different number, types, or order of parameters).
- Return type alone is not sufficient for method overloading.
- Parameter names alone are not sufficient for method overloading.
public class OverloadingExample
{
// Different number of parameters
public void Display(int a)
{
Console.WriteLine($"Display(int): {a}");
}
public void Display(int a, int b)
{
Console.WriteLine($"Display(int, int): {a}, {b}");
}
// Different types of parameters
public void Display(double a)
{
Console.WriteLine($"Display(double): {a}");
}
public void Display(string a)
{
Console.WriteLine($"Display(string): {a}");
}
// Different order of parameters
public void Display(int a, string b)
{
Console.WriteLine($"Display(int, string): {a}, {b}");
}
public void Display(string a, int b)
{
Console.WriteLine($"Display(string, int): {a}, {b}");
}
// Optional parameters
public void Display(int a, int b = 0, string c = "default")
{
Console.WriteLine($"Display(int, int, string): {a}, {b}, {c}");
}
// This would cause a compilation error because it differs only by return type
// public int Display(int a) { return a; }
// This would cause a compilation error because it differs only by parameter name
// public void Display(int x) { Console.WriteLine(x); }
}
Using Method Overloading
OverloadingExample example = new OverloadingExample();
example.Display(10); // Calls Display(int)
example.Display(10, 20); // Calls Display(int, int)
example.Display(10.5); // Calls Display(double)
example.Display("Hello"); // Calls Display(string)
example.Display(10, "Hello"); // Calls Display(int, string)
example.Display("Hello", 10); // Calls Display(string, int)
example.Display(10, 20, "Custom"); // Calls Display(int, int, string)
// Ambiguity can occur with optional parameters
// example.Display(10, 20); // This could call either Display(int, int) or Display(int, int, string)
// The compiler chooses the best match, which is Display(int, int) in this case
3.3.4 - Operator Overloading
Operator overloading is a form of polymorphism that allows operators (such as +, -, *, /, etc.) to be used with custom types. In C#, you can overload operators by defining static methods with the operator keyword.
public class Complex
{
public double Real { get; set; }
public double Imaginary { get; set; }
public Complex(double real, double imaginary)
{
Real = real;
Imaginary = imaginary;
}
// Overloading the + operator
public static Complex operator +(Complex a, Complex b)
{
return new Complex(a.Real + b.Real, a.Imaginary + b.Imaginary);
}
// Overloading the - operator
public static Complex operator -(Complex a, Complex b)
{
return new Complex(a.Real - b.Real, a.Imaginary - b.Imaginary);
}
// Overloading the * operator
public static Complex operator *(Complex a, Complex b)
{
double real = a.Real * b.Real - a.Imaginary * b.Imaginary;
double imaginary = a.Real * b.Imaginary + a.Imaginary * b.Real;
return new Complex(real, imaginary);
}
// Overloading the == operator
public static bool operator ==(Complex a, Complex b)
{
// Need to handle null values
if (ReferenceEquals(a, null))
return ReferenceEquals(b, null);
if (ReferenceEquals(b, null))
return false;
return a.Real == b.Real && a.Imaginary == b.Imaginary;
}
// When overloading ==, you must also overload !=
public static bool operator !=(Complex a, Complex b)
{
return !(a == b);
}
// Overloading the implicit conversion from double to Complex
public static implicit operator Complex(double d)
{
return new Complex(d, 0);
}
// Overloading the explicit conversion from Complex to double
public static explicit operator double(Complex c)
{
return c.Real; // Loses the imaginary part
}
// Override Equals and GetHashCode when overloading == and !=
public override bool Equals(object obj)
{
if (obj is Complex other)
{
return this == other;
}
return false;
}
public override int GetHashCode()
{
return HashCode.Combine(Real, Imaginary);
}
public override string ToString()
{
if (Imaginary >= 0)
{
return $"{Real} + {Imaginary}i";
}
else
{
return $"{Real} - {Math.Abs(Imaginary)}i";
}
}
}
Using Operator Overloading
Complex a = new Complex(3, 4);
Complex b = new Complex(1, 2);
// Using overloaded operators
Complex sum = a + b;
Complex difference = a - b;
Complex product = a * b;
Console.WriteLine($"a = {a}");
Console.WriteLine($"b = {b}");
Console.WriteLine($"a + b = {sum}");
Console.WriteLine($"a - b = {difference}");
Console.WriteLine($"a * b = {product}");
// Using overloaded equality operators
Console.WriteLine($"a == b: {a == b}");
Console.WriteLine($"a != b: {a != b}");
// Using overloaded conversion operators
Complex c = 5.0; // Implicit conversion from double to Complex
Console.WriteLine($"c = {c}");
double d = (double)a; // Explicit conversion from Complex to double
Console.WriteLine($"d = {d}");
// Output:
// a = 3 + 4i
// b = 1 + 2i
// a + b = 4 + 6i
// a - b = 2 + 2i
// a * b = -5 + 10i
// a == b: False
// a != b: True
// c = 5 + 0i
// d = 3
3.3.5 - Interfaces and Polymorphism
Interfaces play a crucial role in polymorphism by allowing objects of different classes to be treated as objects of a common interface type. This enables more flexible and extensible code.
// Interface definition
public interface IShape
{
double CalculateArea();
double CalculatePerimeter();
void Draw();
}
// Implementing the interface in different classes
public class Circle : IShape
{
public double Radius { get; set; }
public Circle(double radius)
{
Radius = radius;
}
public double CalculateArea()
{
return Math.PI * Radius * Radius;
}
public double CalculatePerimeter()
{
return 2 * Math.PI * Radius;
}
public void Draw()
{
Console.WriteLine($"Drawing a circle with radius {Radius}");
}
}
public class Rectangle : IShape
{
public double Width { get; set; }
public double Height { get; set; }
public Rectangle(double width, double height)
{
Width = width;
Height = height;
}
public double CalculateArea()
{
return Width * Height;
}
public double CalculatePerimeter()
{
return 2 * (Width + Height);
}
public void Draw()
{
Console.WriteLine($"Drawing a rectangle with width {Width} and height {Height}");
}
}
public class Triangle : IShape
{
public double SideA { get; set; }
public double SideB { get; set; }
public double SideC { get; set; }
public Triangle(double sideA, double sideB, double sideC)
{
SideA = sideA;
SideB = sideB;
SideC = sideC;
}
public double CalculateArea()
{
// Using Heron's formula
double s = (SideA + SideB + SideC) / 2;
return Math.Sqrt(s * (s - SideA) * (s - SideB) * (s - SideC));
}
public double CalculatePerimeter()
{
return SideA + SideB + SideC;
}
public void Draw()
{
Console.WriteLine($"Drawing a triangle with sides {SideA}, {SideB}, and {SideC}");
}
}
Using Interfaces for Polymorphism
// Creating objects
IShape circle = new Circle(5);
IShape rectangle = new Rectangle(4, 6);
IShape triangle = new Triangle(3, 4, 5);
// Creating a list of shapes
List<IShape> shapes = new List<IShape> { circle, rectangle, triangle };
// Using polymorphism to call methods on different objects
foreach (IShape shape in shapes)
{
shape.Draw();
Console.WriteLine($"Area: {shape.CalculateArea()}");
Console.WriteLine($"Perimeter: {shape.CalculatePerimeter()}");
Console.WriteLine();
}
// Output:
// Drawing a circle with radius 5
// Area: 78.53981633974483
// Perimeter: 31.41592653589793
//
// Drawing a rectangle with width 4 and height 6
// Area: 24
// Perimeter: 20
//
// Drawing a triangle with sides 3, 4, and 5
// Area: 6
// Perimeter: 12
3.3.6 - Dynamic Binding
Dynamic binding is a mechanism where the method to be called is determined at runtime based on the actual type of the object. In C#, this can be achieved using the dynamic keyword, which bypasses static type checking at compile time.
public class DynamicExample
{
public void Method1()
{
Console.WriteLine("Method1 called");
}
public void Method2(int x)
{
Console.WriteLine($"Method2 called with {x}");
}
public string Method3(string s)
{
Console.WriteLine($"Method3 called with {s}");
return s.ToUpper();
}
}
public class AnotherClass
{
public void Method1()
{
Console.WriteLine("AnotherClass.Method1 called");
}
public void Method4()
{
Console.WriteLine("Method4 called");
}
}
Using Dynamic Binding
// Creating objects
DynamicExample example = new DynamicExample();
AnotherClass another = new AnotherClass();
// Using dynamic binding
dynamic dynamicObject = example;
dynamicObject.Method1(); // Calls DynamicExample.Method1()
dynamicObject.Method2(10); // Calls DynamicExample.Method2(int)
string result = dynamicObject.Method3("hello"); // Calls DynamicExample.Method3(string)
Console.WriteLine($"Result: {result}");
// Changing the object that dynamicObject refers to
dynamicObject = another;
dynamicObject.Method1(); // Calls AnotherClass.Method1()
dynamicObject.Method4(); // Calls AnotherClass.Method4()
// This would throw a RuntimeBinderException because Method2 doesn't exist in AnotherClass
// dynamicObject.Method2(10);
// Output:
// Method1 called
// Method2 called with 10
// Method3 called with hello
// Result: HELLO
// AnotherClass.Method1 called
// Method4 called
3.3.7 - Polymorphic Collections
Polymorphism allows you to create collections that can store objects of different types, as long as they share a common base class or interface. This enables you to process objects of different types in a uniform way.
// Base class
public abstract class Animal
{
public string Name { get; set; }
public Animal(string name)
{
Name = name;
}
public abstract void MakeSound();
public virtual void Eat()
{
Console.WriteLine($"{Name} is eating.");
}
}
// Derived classes
public class Dog : Animal
{
public Dog(string name) : base(name) { }
public override void MakeSound()
{
Console.WriteLine($"{Name} says: Woof!");
}
public void Fetch()
{
Console.WriteLine($"{Name} is fetching the ball.");
}
}
public class Cat : Animal
{
public Cat(string name) : base(name) { }
public override void MakeSound()
{
Console.WriteLine($"{Name} says: Meow!");
}
public void Climb()
{
Console.WriteLine($"{Name} is climbing the tree.");
}
}
public class Bird : Animal
{
public Bird(string name) : base(name) { }
public override void MakeSound()
{
Console.WriteLine($"{Name} says: Tweet!");
}
public void Fly()
{
Console.WriteLine($"{Name} is flying.");
}
}
Using Polymorphic Collections
// Creating objects
Dog dog = new Dog("Buddy");
Cat cat = new Cat("Whiskers");
Bird bird = new Bird("Tweety");
// Creating a polymorphic collection
List<Animal> animals = new List<Animal> { dog, cat, bird };
// Processing objects polymorphically
foreach (Animal animal in animals)
{
Console.WriteLine($"Animal: {animal.Name} ({animal.GetType().Name})");
animal.MakeSound();
animal.Eat();
// Using pattern matching to access specific methods
if (animal is Dog d)
{
d.Fetch();
}
else if (animal is Cat c)
{
c.Climb();
}
else if (animal is Bird b)
{
b.Fly();
}
Console.WriteLine();
}
// Output:
// Animal: Buddy (Dog)
// Buddy says: Woof!
// Buddy is eating.
// Buddy is fetching the ball.
//
// Animal: Whiskers (Cat)
// Whiskers says: Meow!
// Whiskers is eating.
// Whiskers is climbing the tree.
//
// Animal: Tweety (Bird)
// Tweety says: Tweet!
// Tweety is eating.
// Tweety is flying.
3.3.8 - Polymorphism Best Practices
When using polymorphism in C#, consider the following best practices:
-
Design for Polymorphism: When designing class hierarchies, think about how derived classes will override or extend base class behavior. Make methods virtual or abstract when they are intended to be overridden.
-
Use Interfaces for Multiple Behaviors: When a class needs to exhibit multiple behaviors, use interfaces rather than trying to force it into a complex inheritance hierarchy.
-
Prefer 'is-a' Relationships for Inheritance: Use inheritance only when there is a true "is-a" relationship between the base and derived classes. For "has-a" relationships, use composition instead.
-
Keep the Liskov Substitution Principle in Mind: Derived classes should be substitutable for their base classes without altering the correctness of the program. This means that derived classes should not change the expected behavior of base class methods.
-
Use Abstract Classes for Common Functionality: When multiple classes share common functionality, consider using an abstract base class to encapsulate that functionality.
-
Be Careful with Method Hiding: Prefer overriding virtual methods to hiding methods with the
newkeyword, as hiding can lead to confusion and bugs. -
Use Dynamic Binding Judiciously: The
dynamickeyword bypasses compile-time type checking, which can lead to runtime errors. Use it only when necessary, such as when working with COM objects or dynamic languages. -
Consider Performance Implications: Virtual method calls are slightly slower than non-virtual calls. In performance-critical code, this difference might be significant.
-
Document Polymorphic Behavior: Clearly document how derived classes should implement or override base class methods, including any assumptions or requirements.
-
Use Pattern Matching for Type-Specific Operations: When you need to perform operations specific to a derived type, use pattern matching (the
isoperator with a type pattern) rather than casting.
Example of Good Polymorphic Design
// Interface for shapes
public interface IShape
{
double CalculateArea();
double CalculatePerimeter();
void Draw();
}
// Interface for resizable objects
public interface IResizable
{
void Resize(double factor);
}
// Interface for colorable objects
public interface IColorable
{
string Color { get; set; }
void SetColor(string color);
}
// Base class for shapes
public abstract class Shape : IShape
{
public string Name { get; protected set; }
protected Shape(string name)
{
Name = name;
}
public abstract double CalculateArea();
public abstract double CalculatePerimeter();
public virtual void Draw()
{
Console.WriteLine($"Drawing a {Name}");
}
public override string ToString()
{
return $"{Name} - Area: {CalculateArea()}, Perimeter: {CalculatePerimeter()}";
}
}
// Circle class
public class Circle : Shape, IResizable, IColorable
{
public double Radius { get; private set; }
public string Color { get; set; }
public Circle(double radius, string color = "Black") : base("Circle")
{
Radius = radius;
Color = color;
}
public override double CalculateArea()
{
return Math.PI * Radius * Radius;
}
public override double CalculatePerimeter()
{
return 2 * Math.PI * Radius;
}
public override void Draw()
{
Console.WriteLine($"Drawing a {Color} circle with radius {Radius}");
}
public void Resize(double factor)
{
Radius *= factor;
}
public void SetColor(string color)
{
Color = color;
}
}
// Rectangle class
public class Rectangle : Shape, IResizable, IColorable
{
public double Width { get; private set; }
public double Height { get; private set; }
public string Color { get; set; }
public Rectangle(double width, double height, string color = "Black") : base("Rectangle")
{
Width = width;
Height = height;
Color = color;
}
public override double CalculateArea()
{
return Width * Height;
}
public override double CalculatePerimeter()
{
return 2 * (Width + Height);
}
public override void Draw()
{
Console.WriteLine($"Drawing a {Color} rectangle with width {Width} and height {Height}");
}
public void Resize(double factor)
{
Width *= factor;
Height *= factor;
}
public void SetColor(string color)
{
Color = color;
}
}
// Triangle class
public class Triangle : Shape, IColorable
{
public double SideA { get; private set; }
public double SideB { get; private set; }
public double SideC { get; private set; }
public string Color { get; set; }
public Triangle(double sideA, double sideB, double sideC, string color = "Black") : base("Triangle")
{
SideA = sideA;
SideB = sideB;
SideC = sideC;
Color = color;
}
public override double CalculateArea()
{
// Using Heron's formula
double s = (SideA + SideB + SideC) / 2;
return Math.Sqrt(s * (s - SideA) * (s - SideB) * (s - SideC));
}
public override double CalculatePerimeter()
{
return SideA + SideB + SideC;
}
public override void Draw()
{
Console.WriteLine($"Drawing a {Color} triangle with sides {SideA}, {SideB}, and {SideC}");
}
public void SetColor(string color)
{
Color = color;
}
}
Using Well-Designed Polymorphism
// Creating objects
Circle circle = new Circle(5, "Red");
Rectangle rectangle = new Rectangle(4, 6, "Blue");
Triangle triangle = new Triangle(3, 4, 5, "Green");
// Using polymorphism with the IShape interface
List<IShape> shapes = new List<IShape> { circle, rectangle, triangle };
foreach (IShape shape in shapes)
{
shape.Draw();
Console.WriteLine($"Area: {shape.CalculateArea()}");
Console.WriteLine($"Perimeter: {shape.CalculatePerimeter()}");
Console.WriteLine();
}
// Using polymorphism with the IResizable interface
List<IResizable> resizableShapes = new List<IResizable> { circle, rectangle };
foreach (IResizable shape in resizableShapes)
{
shape.Resize(1.5);
}
// Using polymorphism with the IColorable interface
List<IColorable> colorableShapes = new List<IColorable> { circle, rectangle, triangle };
foreach (IColorable shape in colorableShapes)
{
shape.SetColor("Purple");
}
// Displaying the shapes after resizing and changing colors
foreach (IShape shape in shapes)
{
shape.Draw();
Console.WriteLine($"Area: {shape.CalculateArea()}");
Console.WriteLine($"Perimeter: {shape.CalculatePerimeter()}");
Console.WriteLine();
}
// Using pattern matching for type-specific operations
foreach (IShape shape in shapes)
{
if (shape is Circle c)
{
Console.WriteLine($"Circle radius: {c.Radius}");
}
else if (shape is Rectangle r)
{
Console.WriteLine($"Rectangle dimensions: {r.Width} x {r.Height}");
}
else if (shape is Triangle t)
{
Console.WriteLine($"Triangle sides: {t.SideA}, {t.SideB}, {t.SideC}");
}
}
In the next section, we'll explore encapsulation, another key principle of object-oriented programming that helps hide the internal state and implementation details of an object.