Part 5.1 — Polymorphism | Classes

Polymorphism is closely linked with inheritance, but it can also be used separately. Polymorphism can be defined as follows, and these definitions will be correct:

  • The ability to use an object without knowing its actual type.
  • The ability for objects with the same specification to have different implementations.
  • The ability to parameterize a type at the start of the application.

In general, according to the OOP philosophy, polymorphism is the ability of an object to take on multiple forms.

There are many forms of polymorphism and numerous features in the C# language for their implementation. We won’t go deep into the academic differences between forms of polymorphism, but we will discuss where and most importantly, why we might use polymorphism practices when writing code in C#.

“In general, polymorphism techniques make the code more generic/universal and add extension points, but they also make it more complex to understand and maintain.”

In this chapter, we’ll discuss the following C# language features and when they might come in handy:

  • The ability to reference a type differently.
  • Overriding and overloading methods.
  • Method result covariance.
  • Generics in classes, delegates, interfaces, and methods.
  • Generic constraints.
  • Covariance/Contravariance in generic delegates and interfaces.

Polymorphism and Virtual Methods

In general, the mechanism of virtual methods is relatively easy to understand. If a method in the base class is marked as virtual, and in derived classes as override, then when creating an instance of a particular derived class, the type of reference through which it’s passed doesn’t matter. What matters is the specific instance that was created; its method will be invoked.

However, there are more complex chains, for example:

public class A
{
public virtual void Method()
{
Console.Write("A");
}
}

public class B:A
{
public virtual new void Method()
{
Console.Write("B");
}
}

public class C:B
{
public override void Method()
{
Console.Write("C");
}
}

Let’s consider the following calls:

A a = new B();
B b = new B();
B b2 = new C();
A c = new C();
a.Method(); //A

In this case, the method of class A will be invoked, even though an instance of class B was created. This happens because the method from class B isn’t really an override, and polymorphism won’t work here. The virtual keyword by itself doesn’t do the trick.

b.Method(); //B

In this scenario, the method of the class that was created will be invoked. No chains affect it.

b2.Method(); //C

In this instance, the classic principle of polymorphism applies. We have a reference of type B, in which the method is marked as virtual. In class C, the method is marked as override. Since we created an object of type C, the method of type C will be invoked in accordance with polymorphism, regardless of the fact that we have a reference of type B.

c.Method(); //A

A more cunning, though fairly straightforward case. As C is a descendant of A, the instance was created without issues. However, since the override chain was broken at B, the method of class A will be invoked.

In case we modify the class B in the following way:

public class B : A
{
public override void Method()
{
Console.Write(“B”);
}
}

By removing the keyword “new” and adding the keyword “override”, the chain of virtual methods will work from A to C, and the current code:

A a = new B();
B b = new B();
B b2 = new C();
A c = new C();

Will output “BBCC”, meaning the method will be invoked from the actual types.

Method Overloading

We’ve covered method overriding, but there’s also the concept of “method overloading.”

An overloaded method is a method with the same return type and name but with a different number of parameters. In designing your classes, it’s often more appropriate to create two identical methods with different numbers of parameters than to complicate the code with one method filled with numerous optional parameters.

Covariant Return Types

With the introduction of C# 9, a new feature called “covariant return type” was added. It works as follows:

Given a class hierarchy:

class BaseResult {}
class NestedResult : BaseResult {}

It became possible to use a derived class as the return type of a method we’re overriding.

public class A
{
public virtual BaseResult Method()
{
return new BaseResult();
}
}
public class B : A
{
public override NestedResult Method()
{
return new NestedResult();
}
}

This feature allows developers to provide more specific return types in derived classes, which can be very useful for enhancing type safety and code readability. Before C# 9, the overridden method in a derived class had to return the exact same type (or a type compatible with it through a conversion) as the method in the base class.

Now, with covariant returns, the overridden method can return a more derived type, as shown in the example.

Generics in Classes, Delegates, Interfaces, and Methods

Generics truly unlock vast possibilities in terms of designing universal code. In essence, generics are a direct implementation of polymorphic behavior. In C#, generics are bound at runtime, not at compile time.

Generics allow for the use of a specific type to template another type. Generics can also have constraints, which more precisely specify the type that can be used to template another type. By their nature, generics create extension points in the design of an application, minimizing the need to write redundant code. The simplest example is the List<T> class, where T is the generic type. By its nature, List is a container for elements, but to make this container universal, it is implemented with a generic parameter, which is used in almost all of the List’s methods.

  • The first reason for using generics is to achieve cleaner and more universal code without unnecessary type casting operations.
  • The second reason is that type casting is a costly operation, and it should be avoided.

Generic Constraints (Constraints on Type Parameters)

Constraints in generics are required to provide code using a generic parameter reference with a better understanding of what properties the generic type will have, such as whether it will have a public constructor or implement a particular interface.

Generally, the following types of constraints exist:

1. Broad constraints (you can choose only one from this group):

  • class: The type argument must be a reference type; this applies also to any class, interface, delegate, or array type.
  • class?: The type argument must be a nullable reference type.
  • struct: The type argument must be a value type. Any value type except Nullable can be specified.
  • notnull: The type argument must be a non-nullable value type or non-nullable reference type.
  • unmanaged: The type argument must be a non-nullable unmanaged type.

2. Specific constraints (these can overlap):

  • BaseClass: The type argument must be or derive from the specified base class.
  • BaseClass?: The type argument must be a nullable type derived from the specified base class.
  • BaseInterface: The type argument must be or implement the specified interface.
  • BaseInterface?: The type argument must be a nullable type and implement the specified interface.

3. Constructor constraint:

  • new(): The type argument must have a public parameterless constructor. This constraint can’t be used with struct & unmanaged.

It is also permissible when one generic parameter inherits from another generic parameter. For example:

class GClass<T1, T2>
where T2 : new()
where T1 : T2
{
// … implementation
}

This GClass class has constraints indicating that T2 must have a parameterless constructor, and T1 must be, or derive from, T2. Constraints provide a way to express the capabilities a type of argument must have. Without any constraints, the type argument could be any type. The compiler can only assume the members of System.Object, is the top-most base class in the .NET hierarchy.

to be continued …

--

--