61. Run-time type vs compile-time type#

To understand why subtype polymorphism is important we must understand dynamic dispatch, but to understand dynamic dispatch we must first understand the difference between ‘compile-time types’ and ‘run-time types’. Let’s take a closer look at these terms and why they are important.

In the simplest terms, the compile-time type of a variable is the type that the compiler recognizes when compiling your code. It restricts you to call only those methods and properties on the variable that belong to its compile-time type. In contrast, the run-time type of a variable is the actual type of the object that the variable holds during the execution of the program.

Tip

Run-time type is what it actually is, and compile-time type is what we say that it is.

../_images/cover-run-time-type-vs-compile-time-type.jpg

Fig. 61.1 The tip of the iceberg, above the water’s surface, is like the compile-time type of an object – what the compiler can ‘see’ and verify based on the declared type of a variable. However, beneath the surface lies the vast majority of the iceberg, unseen until run-time. This is like the run-time type of the object behind the variable - the actual type of the object in memory when the program is executed. While the compile-time type provides a limited view of the object, the run-time type reveals its full identity, much like the hidden depth of the iceberg beneath the water’s surface.#

For instance, if you have a variable of type IShape holding a Rectangle object, you can only call the methods and properties declared in the IShape interface, even though the actual object (i.e., Rectangle) may have additional methods and properties. We say that we ‘treat’ a Rectangle as an IShape.

Let’s assume that IShape only demands that its implementors define a property with a getter called Area and that Rectangle defines Area but also Width and Height.

interface IShape
{
    double Area { get; }
}
class Rectangle : IShape
{
    public double Width { get; set; }
    public double Height { get; set; }
    public double Area => Width * Height;
}

Let’s then declare a variable of type IShape but assign it an object of type Rectangle.

IShape shape = new Rectangle();

We can now compile code that calls the getter of shape.Area.

var x = shape.Area;

But we cannot compile code that calls shape.Width. Because the compiler only knows that IShape contains a get-only property called Area.

shape.Width = 10;
(1,7): error CS1061: 'IShape' does not contain a definition for 'Width' and no accessible extension method 'Width' accepting a first argument of type 'IShape' could be found (are you missing a using directive or an assembly reference?)

Important

What methods we can call is determined by the compile-time type.

The compile-time type of a variable, as the name suggests, is the type that the compiler sees when it’s compiling your program. It determines what methods and properties the compiler will allow you to call on that variable. If you attempt to use a method or property that isn’t part of the compile-time type, you’ll get a compilation error, even if the actual object at run-time would support that method or property.

The compile-time type of the variable shape is IShape. This means that you can only call the methods and properties declared in the IShape interface on shape, even though it might actually be holding a Rectangle object that has additional methods.

In contrast, the run-time type of a variable is the actual type of the object that the variable references at run-time. It can be the same as the compile-time type, but it can also be any subtype of the compile-time type.

Even though the compile-time type of shape is IShape, the run-time type is Rectangle. The shape variable is actually holding a Rectangle object at run-time. This means that the actual implementation of the methods and properties that we invoke come from Rectangle. We’ll talk more about this in the chapter on dynamic dispatch.

Important

Which implementation of the method that is executed is determined by the run-time type.

There’s a trade-off when treating a subtype as its supertype. On one hand, we lose access to the specialized behaviors and properties that the subtype offers, as our reference to the object is now limited to the more general features defined by the supertype. This is akin to focusing on the broader category of ‘fruit’ instead of the specific ‘banana’ or ‘apple’.

On the other hand, we gain a form of flexibility: our code becomes capable of dealing with any subtype that shares this supertype. This generalization empowers us to write code that’s more versatile and reusable, capable of working with a whole range of objects within the general ‘fruit’ category, rather than being tied to a single specific subtype. So, while we might not be able to access certain specific features, the ability to handle a broader variety of objects can often be a valuable advantage.

Key point

The compile-time type of a variable determines what operations you can perform on the variable in your code, whereas the run-time type determines what actual operations occur when your code is executed.

In the future chapters on upcasting and downcasting, we will discuss how we can temporarily regain ‘lost’ information by using techniques like downcasting or the Visitor design pattern. But for now, it’s essential to remember that the compile-time type restricts what methods and properties you can call in your code, and it’s the run-time type that determines what actual methods get called when your code runs.