In this post, we are going to learn about the different types of polymorphism in C#, how they work and how we can use them in our code.
Let’s start.
Types of Polymorphism in C#
There are two types of polymorphism:
- Compile Time Polymorphism (method overloading)
- Run-Time Polymorphism (method overriding)
Also, generic programming is sometimes referred to as another type of polymorphism (parametric polymorphism). That’s because a generic class can operate in a different way, depending on the classes we use as type parameters.
You can find more about generic programming in our article about C# Generics.
Compile-Time Polymorphism
Compile-time polymorphism in C# is the existence of multiple methods with the same name, but with different arguments in type and/or number. We also call it method overloading. We can use it in situations where we need to implement multiple methods with similar functionality and we opt to give them the same name:
public class Logger { private StreamWriter LogFile; public Logger(StreamWriter logFile) { LogFile = logFile; } public void Log(string message, LogLevels level) { LogFile.Write(level + " ---"); LogFile.Write(DateTime.Now.ToString() + " ---"); LogFile.WriteLine(message); LogFile.Flush(); } public void Log(string message) { Log(message, LogLevels.Info); } public void Log(string message, int level) { if (Enum.IsDefined(typeof(LogLevels), level)) { Log(message, (LogLevels)Enum.Parse(typeof(LogLevels), level.ToString())); } else { throw new Exception("Log level value does not exist"); } } }
In this example, class Logger
defines 3 methods and makes use of a LogLevels
enum:
public enum LogLevels { Info = 1, Warning = 2, Error = 3 }
The second method provides a default value (Info) for the log level. The third method accepts an integer as input and converts it to the corresponding log level. Both methods eventually use the first one, in order to write the message to the log file.
A special case of method overloading is operator overloading. Here we can define multiple implementations for each operator method. Also, constructors are frequently overloaded; we can have constructors with multiple parameters as well as the default constructor.
Another reason to use method overloading is when we need to change the definition of a method by adding one or more parameters to it. Since this change will probably break existing code, sometimes it is preferable to introduce a new method that will overload the initial one.
Run-Time Polymorphism
In the case of run-time polymorphism in C#, an object behaves in a different way depending on the context in which it is used.
How it works
To understand the concepts better, let’s define a good example that can help us.
First, let’s create a Package
class defines the common information necessary to deliver a package to its destination (recipient name and address). It also defines the characteristics of the package (weight and receipt date).
The Package
class also defines two methods: GetDeliveryCost()
and GetDeliveryDate()
. Those methods calculate the expected cost and delivery date of the package. We base the calculation on a package weight, and receipt date:
public class Package { public string Recipient { get; set; } public string Address { get; set; } public decimal Weight { get; set; } public DateTime SendDate { get; set; } // ... public decimal GetDeliveryCost() { return 3 * Weight; } public DateTime GetDeliveryDate() { if (SendDate.DayOfWeek == DayOfWeek.Friday) return SendDate.AddDays(4); if (SendDate.DayOfWeek == DayOfWeek.Thursday) return SendDate.AddDays(4); else return SendDate.AddDays(2); } }
We’re also going to add another class called ExpeditedPackage
which inherits from the Package
class.
This class has a slightly different delivery cost and delivery date calculation:
public class ExpeditedPackage : Package { // ... public new decimal GetDeliveryCost() { return 4 * Weight + 2; } public new DateTime GetDeliveryDate() { return SendDate.AddDays(1); } }
And we’ll add another class called InternationalPackage
which takes the destination country into account:
public class InternationalPackage : Package { public string CountryCode { get; set; } // ... public new decimal GetDeliveryCost() { if (CountryCode == "US") return 6 * Weight + 3; else if (CountryCode == "UK") return 5 * Weight + 4; else if (CountryCode == "DE") return 6 * Weight; else return 6 * Weight + 2; } public new DateTime GetDeliveryDate() { if (CountryCode == "US") return SendDate.AddDays(3); else if (CountryCode == "UK") return SendDate.AddDays(2); else if (CountryCode == "DE") return SendDate.AddDays(1); else return SendDate.AddDays(2); } }
With polymorphism, we can have a base class reference point to an object of a subclass:
ExpeditedPackage ep = new ExpeditedPackage("Sender B", "Address B", 10, DateTime.Now); Package p = ep;
Note that this operation is only possible due to the base class – subclass relationship between Package
and ExpeditedPackage
. It does not work the other way round: for instance, we cannot have an ExpeditedPackage
reference pointing to a Package
object:
Package p = new Package("Sender A", "Address A", 10, DateTime.Now); ExpeditedPackage ep = p; //compiler error
Furthermore, it’s not possible for unrelated classes to point at each other:
Package p = new Package("Sender A", "Address A", 10, DateTime.Now); Person pr = p; //compiler error
Now, let’s try to call the GetDeliveryCost()
method using the reference to the ExpeditedPackage
:
ExpeditedPackage ep = new ExpeditedPackage("Sender B", "Address B", 10, DateTime.Now); Package p = ep; Console.WriteLine("Cost: " + p.GetDeliveryCost());
We can see that we actually call the method defined in the Package
class. This happens because the reference of type Package
knows only about the inner workings of the Package
class. This behavior will change as soon as we introduce virtual methods into our classes.
Polymorphism with Virtual Methods
Now, let’s insert the virtual
keyword in the declaration of the two methods in the Package
class:
public class Package { // ... public virtual decimal GetDeliveryCost() { return 3 * Weight; } public virtual DateTime GetDeliveryDate() { if (SendDate.DayOfWeek == DayOfWeek.Friday) return SendDate.AddDays(4); if (SendDate.DayOfWeek == DayOfWeek.Thursday) return SendDate.AddDays(4); else return SendDate.AddDays(2); } }
We need to mark the respective methods in the ExpeditedPackage
class with the override
keyword in order to make them override the logic of the Package
base class methods:
public class ExpeditedPackage : Package { // ... public override decimal GetDeliveryCost() { return 4 * Weight + 2; } public override DateTime GetDeliveryDate() { return SendDate.AddDays(1); } }
Now we get the expected delivery cost and date from the methods of the ExpeditedPackage
class.
The virtual
keyword modifies the way the reference works. Now, we can call methods of theExpeditedPackage
subclass, through a reference of the base class type Package
.
Polymorphism with Abstract Classes
We can also leverage the concept of abstract classes to make polymorphism work in our code. Let’s consider a variation of the packages hierarchy. Here we define an abstract base class Package
that contains the necessary properties of the package. We also declare the two methods as abstract. This means that we cannot instantiate an object of the type Package
:
public abstract class Package { // ... public abstract decimal GetDeliveryCost(); public abstract DateTime GetDeliveryDate(); }
However, we can use Package
as a base class to derive subclasses from. Now we move the functionality of the base package to a new subclass, BasePackage
and we implement the two methods:
public class BasePackage: Package { // ... public override decimal GetDeliveryCost() { return 3 * Weight; } public override DateTime GetDeliveryDate() { if (SendDate.DayOfWeek == DayOfWeek.Friday) return SendDate.AddDays(4); if (SendDate.DayOfWeek == DayOfWeek.Thursday) return SendDate.AddDays(4); else return SendDate.AddDays(2); } }
We can use this approach if there is no point in having concrete objects in the base class. We can also use it when we do not want to implement some of the methods of the base class and we decide to leave them abstract.
Abstract classes bear similarity to interfaces, as we can use both concepts to make a group of classes provide the same functionality. However, there are some differences. The most important difference is the fact that a class can inherit only from one base class, but it can implement multiple interfaces.
Sealed and New Keywords
When we declare a method as virtual
, it remains virtual
in all subclasses down the class hierarchy. This means that in a class that derives from ExpeditedPackage
(e.g. PremiumExpeditedPackage
) we can provide a new definition of both virtual methods. We can prevent this behavior by marking those methods as sealed
in ExpeditedPackage
:
public class ExpeditedPackage : Package { // ... public sealed override decimal GetDeliveryCost() { return 4 * Weight + 2; } public sealed override DateTime GetDeliveryDate() { return SendDate.AddDays(1); } }
If we try to override any of the methods in PremiumExpeditedPackage
, we’ll get a compiler error. We can still provide new functionality to PremiumExpeditedPackage
by using the keyword new
to those methods:
public class PremiumExpeditedPackage : ExpeditedPackage { // ... public new decimal GetDeliveryCost() { return 6 * Weight + 3; } public new DateTime GetDeliveryDate() { return SendDate; } }
However, if we try to call those methods through a reference of type Package
, we will see that we will actually call the sealed methods of ExpeditedPackage
, since they are the last step in the chain of virtual methods in the hierarchy.
Access Base Class Members
If we need to, we can use the keyword base
to get access to virtual members of the base class:
public class InternationalPackage : Package { // ... public override decimal GetDeliveryCost() { if (CountryCode == "US") return base.GetDeliveryCost(); else if (CountryCode == "UK") return 5 * Weight + 4; else if (CountryCode == "DE") return 6 * Weight; else return 6 * Weight + 2; } // ... }
Here, we treat international packages sent to the US as simple packages, both in terms of cost and delivery date. We accomplish this by calling the respective methods of the base Package
class.
Things to Consider
For the run-time polymorphism concept to work, both methods, in the base class and the subclass, should have exactly the same signature with the exception of the return type.
We can only access the properties and methods that are defined in the base class. For instance, we cannot access property CountryCode
from InternationalPackage
, as it is only defined in the subclass.
Where to Use Run-Time Polymorphism in C#
By using the run-time polymorphism we get many advantages. Let’s cover the most important ones with a practical example:
public class CourierBranch { public string Name { get; set; } public string Address { get; set; } public List<Package> packages { get; set; } public CourierBranch(string name, string address) { Name = name; Address = address; packages = new List<Package>(); } public void AddPackage(Package newPackage) { packages.Add(newPackage); } public decimal GetTotalCost() { decimal totalCost = 0; foreach (var package in packages) { totalCost += package.GetDeliveryCost(); } return totalCost; } public void PrintList() { foreach (var package in packages) { Console.WriteLine("Cost: " + package.GetDeliveryCost()); Console.WriteLine("Delivery date: " + package.GetDeliveryDate()); } } }
Here we’ve implemented a new class CourierBranch
that abstracts the work of a local branch in a package courier company. Each branch maintains a list of all packages that it should deliver.
In this example, we will add packages of all types for delivery and we will get the calculated costs and delivery dates for those packages:
CourierBranch Branch1 = new CourierBranch("Branch 1", "12 Main str."); Branch1.AddPackage(new Package("Sender A", "Address A", 10, DateTime.Now)); Branch1.AddPackage(new ExpeditedPackage("Sender B", "Address B", 10, DateTime.Now)); Branch1.AddPackage(new InternationalPackage("Sender C", "Address C", 10, DateTime.Now, "US")); Branch1.PrintList();
Benefits of Run-Time Polymorphism in C#
Run-time polymorphism is an extremely useful concept.
We decouple our code. Consider the case where a new type of package is introduced sometime in the future. Note that the CourierBranch
class handles only references to the Package
class and not to any of its subclasses. Therefore, there will be no need to modify it to handle a new type of package. This way, the CourierBranch
class is decoupled from the specifics of packages and thus makes our code more maintainable.
We can store multiple types of objects in one common list. Without polymorphism, we would have to maintain a separate list for each type of Package
we use. With the use of polymorphism, we can store all package types in the same list and we can handle them in a similar fashion. Also, the definition of a new package type will have no effect on this class. We won’t have to add yet another list for the new package type.
We can pass multiple types of objects in the same method. The AddPackage()
method can take all types of packages as input arguments. To achieve this, we’ve defined the input of this method to be of the Package
base type. In the absence of run-time polymorphism, we would have to define three AddPackage()
methods, one for each type of package available. Furthermore, we would have to modify the CourierBranch
class and add another AddPackage()
method, in the case of a new package offering.
We avoid the use of switch/case (or if/else) blocks and the typeof
operator. Without the run-time polymorphism, we would have to use a simple Package
class. This class would contain a member variable Type
describing the type of the package. In this case, we would have to maintain an if-else block for the calculation of the delivery date and cost. The introduction of a new type of package would require the modification of the Package
class. This may also affect other places in our code that depend on this class:
public class Package { public string Type { get; set; } public string Recipient { get; set; } public string Address { get; set; } public decimal Weight { get; set; } public DateTime SendDate { get; set; } public string CountryCode { get; set; } // ... public decimal GetDeliveryCost() { if (Type == "Expedited") return 4 * Weight + 2; if (Type == "International") { if (CountryCode == "US") return 6 * Weight + 3; else if (CountryCode == "UK") return 5 * Weight + 4; else if (CountryCode == "DE") return 6 * Weight; else return 6 * Weight + 2; } else return 3 * Weight; } // ... }
Okay, that’s it for this lengthy but extremely important topic.
Conclusion
In this article, we have learned about the two types of polymorphism (compile-time and run-time) and we have seen ways to use this concept in our code.