Understanding the Limitations of Single Dispatch in C#

Understanding the Limitations of Single Dispatch in C#
Photo by Enguerrand Blanchy / Unsplash

When we write code, types help us transform complex ideas into manageable pieces. We often arrange these types in hierarchies, starting with specific implementations at the base and moving up to broader, more abstract concepts. This setup allows us to focus on what truly matters without getting bogged down by unnecessary details, making our work more efficient. However, when our code runs, it’s crucial that we invoke concrete behaviours rather than relying solely on the abstractions that guided us during the design phase.

At runtime, the concrete behaviour is selected based on the actual type information—a process known as dynamic dispatch. This mechanism, enabled by polymorphism and late binding, allows the runtime to select the appropriate method based on the type of the invoking object. This process is known as single dispatch. However, there are cases where behaviour depends on the runtime types of two interacting objects. This is where double dispatch comes into play.

Double dispatch allows a method to select its implementation based on the runtime types of both the object that calls the method and the object being passed as an argument, enabling more versatile method selection. While most modern object-oriented programming languages, including C#, do not natively support double dispatch, we can simulate this behaviour using various approaches.

In this article and the following one, we’ll explore how to implement double dispatch in C# through a practical example. By the end of this series, you’ll have a clearer understanding of when and how to apply double dispatch, as well as how to leverage it to create more maintainable and adaptable code.

A Payment Provider Example

Consider a payment provider that operates by charging fees for different payment methods. Each payment method—such as credit cards, digital wallets, and bank transfers—might incur a different fee, and the calculation of this fee could depend on the specific geographic or regulatory area where payments are processed under different rules and conditions.

To illustrate this, we'll define a set of payment types and simulate fee calculations. We'll start with a simple implementation where each payment type has a default fee. As we delve deeper, we'll show how to handle more complex scenario where different payment types and regions interact. This approach will help us understand how double dispatch can be simulated in C# to achieve type-specific behaviour based on runtime type information and late binding.

Understanding Single Dispatch

To start, we'll define the IPayment interface, which represents the common behaviour required from all payment types. This interface includes a method for calculating fees, ensuring that every concrete payment type adheres to a consistent contract:

public interface IPayment
{
    // Other payment-related methods are omitted for brevity
    
    decimal CalculateFee();
}

IPayment.cs

The following code shows a concrete implementation of IPayment for handling card payments, which covers both credit and debit cards:

public class CardPayment : IPayment
{
    public decimal CalculateFee()
    {
        // More complex fee calculation logic is implemented here,
        // but a fixed value is used for brevity
        
        return 0.50m;
    }
}

CardPayment.cs

Following that, the next code snippet demonstrates another concrete implementation of IPayment for digital wallets. This includes popular methods such as Apple Pay, Google Pay etc.:

public class DigitalWalletPayment : IPayment
{
    public decimal CalculateFee()
    {
        // More complex fee calculation logic is implemented here,
        // but a fixed value is used for brevity
        
        return 1.00m;
    }
}

DigitalWalletPayment.cs

And finally, the last supported payment type models bank transfers:

public class BankTransferPayment : IPayment
{
    public decimal CalculateFee()
    {
        // More complex fee calculation logic is implemented here,
        // but a fixed value is used for brevity
        
        return 2.50m;
    }
}

BankTransferPayment.cs

The three implementations can be visualised on the diagram:

classDiagram
    class IPayment {
        <<interface>>
        +decimal CalculateFee()
    }

    class CardPayment {
        +decimal CalculateFee()
    }

    class DigitalWalletPayment {
        +decimal CalculateFee()
    }

    class BankTransferPayment {
        +decimal CalculateFee()
    }

    IPayment <|-- CardPayment
    IPayment <|-- DigitalWalletPayment
    IPayment <|-- BankTransferPayment

Concrete implementations of IPayment

The fee depends solely on the type of the payment we're dealing with and can be represented as a simple, one-dimensional mapping:

All payment types have default fees

And here we have a very simple client code, which focuses on calculating fee based on the provided payment type:

IPayment payment = new DigitalWalletPayment();

var fee = payment.CalculateFee();

Fee calculation for DigitalWalletPayment

After assigning a concrete implementation—such as CardPayment, DigitalWalletPayment, or BankTransferPayment—to the payment variable of type IPayment (thanks to polymorphism), the runtime invokes the correct CalculateFee method on the appropriate IPayment implementation (that’s late binding in action), based on the type of the payment object. As a result, the fees are calculated as 0.5, 1.0, and 2.5 for CardPayment, DigitalWalletPayment, and BankTransferPayment respectively. This operation, known as single dispatch, is natively provided by C# at no additional cost.

The Need for Double Dispatch

That's all good, and everyone is happy. But then, all of a sudden, the requirements change. The calculation of the fee can no longer depend solely on the payment type. It must also take into account the region where the transaction occurred.

The concept of a region refers to a geographic or regulatory area that dictates specific rules and requirements for processing payments. Different regions may have varying tax rates, processing fees, or compliance regulations that affect the final fee charged to customers. In our example, we introduce two concrete classes representing broad regions, such as Europe and North America:

public abstract class Region
{
    // Common behavior with abstract members to be defined in derived classes
}

public class Europe : Region
{
    // Specific members for Europe region
}

public class NorthAmerica : Region
{
    // Specific members for NorthAmerica region
}

Abstract Region and its concrete implementations: Europe and NorthAmerica

It’s worth noting that even though this type hierarchy begins with an abstract class rather than an interface, the distinction does not change the fundamental purpose: both abstract classes and interfaces are language constructs used to define abstractions.

This new requirement introduces the need for updating the solution, as the fee calculation now depends on both the payment type and the transaction's region. With a unique way of inferring the fee based on these two factors, we can summarise this relationship in a two-dimensional table:

Fees depend on payment type and transaction region

Let's attempt to model this. The updated interface for IPayment would look like this:

public interface IPayment
{
    // Other payment-related methods are omitted for brevity
    
    decimal CalculateFee(Region region);
}

IPayment.cs

So, the CardPayment implementation could be structured as follows:

public class CardPayment : IPayment
{
    public decimal CalculateFee(Region region)
    {
        return CalculateFeeForRegion(region); // Compile error
    }

    private decimal CalculateFeeForRegion(Europe region)
    {
        // More complex fee calculation logic is implemented here,
        // but a fixed value is used for brevity
        
        return 0.60m;
    }

    private decimal CalculateFeeForRegion(NorthAmerica region)
    {
        // More complex fee calculation logic is implemented here,
        // but a fixed value is used for brevity
        
        return 0.40m;
    }
}

CardPayment.cs

However, that approach would result in a compile error:

Cannot resolve method 'CalculateFeeForRegion(Region)', candidates are:
decimal CalculateFeeForRegion(Europe) (in class CardPayment)   
decimal CalculateFeeForRegion(NorthAmerica) (in class CardPayment)

Compile-time exception showing method resolution failure

That’s understandable since the region parameter in CalculateFee is of type Region, which prevents the compiler from matching it to the private, region-specific methods CalculateFeeForRegion(Europe region) and CalculateFeeForRegion(NorthAmerica region). Let’s make a more drastic change to the IPayment interface to satisfy the compiler:

public interface IPayment
{
    // Other payment-related methods are omitted for brevity
    
    decimal CalculateFee(Region region);

    decimal CalculateFee(Europe region);

    decimal CalculateFee(NorthAmerica region);
}

IPayment.cs

Here’s an example of the CardPayment implementation:

public class CardPayment : IPayment
{
    public decimal CalculateFee(Region region)
    {
        throw new InvalidOperationException("Unsupported region");
    }

    public decimal CalculateFee(Europe region)
    {
        return 1.10m;
    }

    public decimal CalculateFee(NorthAmerica region)
    {
        return 0.90m;
    }
}

CardPayment.cs

No compiler error this time, but when we execute the following client code:

IPayment payment = new CardPayment();
Region region = new NorthAmerica();

var fee = payment.CalculateFee(region);

Client code

we encounter a runtime error:

System.InvalidOperationException: Unsupported region
   at CardPayment.CalculateFee(Region region)

Runtime error showing inability to select method based on region type

The reason for the exception is that, while the correct IPayment type (CardPayment) is identified, we still cannot resolve and invoke the appropriate method based on the runtime type of the Region parameter. This highlights a limitation in C#'s built-in support for dynamic dispatch, which only provides single dispatch. In our case, we aim for the first dispatch to occur at runtime when the correct CalculateFee method is invoked on the appropriate IPayment implementation. The second dispatch then happens when the actual type of the Region input parameter is resolved, triggering the corresponding behaviour.

Let’s take a step back and rethink our approach. The challenges we encountered clearly illustrate the need to simulate double dispatch. In the follow-up article, I will present various solutions to this problem, along with their pros and cons. After all, in software engineering, there’s rarely a one-size-fits-all solution.

Stay tuned!

Read more