How to Simulate Double Dispatch in C#

How to Simulate Double Dispatch in C#
Photo by Quality Pixels / Unsplash

Continuing from where we left off in the first part of the article, we will build upon the payment provider example as we explore various ways to simulate double dispatch in C#. Each approach will be accompanied by its respective advantages and disadvantages, providing you with a solid understanding of how and when to implement these techniques effectively in your code.

Explicit Type Checking

From the first part of the article, we realised that we cannot resolve and invoke the correct behaviour based on the runtime type of the declared parameter, which is an abstract Region at compile time. To mitigate this limitation, we can perform explicit type checks at runtime and invoke the desired behaviour accordingly. Let's explore how we can implement the CardPayment class to achieve this:

public class CardPayment : IPayment
{
    public decimal CalculateFee(Region region)
    {
        if (region is Europe europe)
        {
            return CalculateFeeForRegion(europe);
        }

        if (region is NorthAmerica northAmerica)
        {
            return CalculateFeeForRegion(northAmerica);
        }

        throw new InvalidOperationException("Unsupported region");
    }
    
    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

In the above example CalculateFee method, which takes a parameter of type Region, explicitly checks types using is operator. First if the region parameter is of type Europe or NorthAmerica. If it matches either type, it casts the parameter to that type and invokes the corresponding private method (CalculateFeeForRegion), which contains the logic for calculating fees specific to each region. Moreover if the region parameter does not match any of the expected types, an InvalidOperationException is thrown, indicating that the region is unsupported.

The CalculateFee method in the other IPayment implementations (such as DigitalWalletPayment and BankTransferPayment) should follow the same approach, which may lead to duplicated code. We can resolve this issue by moving the logic to a common base class. The newly introduced Payment base class could look as follows:

public abstract class Payment : IPayment
{
    public decimal CalculateFee(Region region)
    {
        if (region is Europe europe)
        {
            return CalculateFeeForRegion(europe);
        }

        if (region is NorthAmerica northAmerica)
        {
            return CalculateFeeForRegion(northAmerica);
        }

        throw new InvalidOperationException("Unsupported region");
    }
    
    protected abstract decimal CalculateFeeForRegion(Europe region);
    
    protected abstract decimal CalculateFeeForRegion(NorthAmerica region);
}

Payment.cs

And a concrete payment type class, such as DigitalWalletPayment, would look like this:

public class DigitalWalletPayment : Payment
{
    protected override decimal CalculateFeeForRegion(Europe region)
    {
        // More complex fee calculation logic is implemented here,
        // but a fixed value is used for brevity
        
        return 1.10m;
    }

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

DigitalWalletPayment.cs

While this approach allows us to work around C#'s lack of native double dispatch support, it comes with a few trade-offs. Let's summarise the pros and cons.

Pros:

  • Simplicity: It is straightforward to understand and implement.
  • Control: It gives you fine-grained control over how methods are selected based on runtime types, allowing for clear handling of different scenarios.

Cons:

  • Maintainability: As the number of region types increases, the method CalculateFee needs to be updated and can become cumbersome, requiring more if checks. That may lead to maintenance problems and to a violation of the Open/Closed Principle.
  • Performance: While newer .NET frameworks are optimised for performance, explicit type checking can still introduce overhead compared to other methods, particularly in high-performance scenarios where checks are frequent.

Best Use Cases:

  • Smaller scope: It is best suited for scenarios where the number of types is relatively small and stable, minimising maintainability concerns. It’s particularly effective when no additional regions are planned for support, as adding each new region would require updates across payment types, potentially leading to a maintenance challenge.
  • Prototyping: It can be useful in early development stages when rapid implementation is needed to prove the concept, without yet addressing the overhead of more complex use cases.

Virtual Method Table

With a slight modification to our new base Payment class, we can simulate double dispatch using a virtual method table. This approach leverages a dictionary to map region types to specific methods, simplifying our fee calculation process. Here’s how it looks:

public abstract class Payment : IPayment
{
    private readonly Dictionary<Type, Func<Region, decimal>> _feeCalculationMap;

    protected Payment()
    {
        _feeCalculationMap = new Dictionary<Type, Func<Region, decimal>>
        {
            { typeof(Europe), region => CalculateFeeForRegion((Europe)region) },
            { typeof(NorthAmerica), region => CalculateFeeForRegion((NorthAmerica)region) }
        };
    }

    public decimal CalculateFee(Region region)
    {
        if (_feeCalculationMap.TryGetValue(region.GetType(), out var calculateFeeMethod))
        {
            return calculateFeeMethod(region);
        }

        throw new InvalidOperationException("Unsupported region");
    }
    
    protected abstract decimal CalculateFeeForRegion(Europe region);
    
    protected abstract decimal CalculateFeeForRegion(NorthAmerica region);
}

Payment.cs

In this approach, we introduce a dictionary that associates each region type, such as Europe or NorthAmerica, with a pointer to a specific method (CalculateFeeForRegion) responsible for calculating the fee for that region. Within the CalculateFee method, we perform a lookup to retrieve the appropriate fee-calculation method based on the runtime type of the region. If a match is found, it invokes the corresponding calculation method, casting the region to the correct type. If there is no match, the method will throw an InvalidOperationException.

Pros:

  • Flexibility: The dictionary allows for easy updates if new region types are added, as it separates the CalculateFeeForRegion methods from the actual method invocation.
  • Efficiency: This approach avoids repetitive if statements or switch cases, potentially improving readability and performance.

Cons:

  • Complexity: While flexible, this approach can introduce extra complexity for developers unfamiliar with delegate-based lookups.
  • Limited type safety: Explicit type-casting is required for each method in the dictionary, meaning runtime errors may occur if types aren’t correctly handled. This may happen if new region types are added later without updating the dictionary or the associated methods, any attempt to call CalculateFeewith these unrecognised types would lead to a runtime exception when the method tries to cast the region to the wrong type.
  • Maintainability: Manual upkeep of the dictionary mapping can easily lead to oversights, resulting in runtime errors if types aren’t correctly handled or if mappings are missed.

Best Use Cases:

  • Moderate scope: It works well if you have a moderate number of region types that may change occasionally. However, with an increased volatility in the collection of regions, maintaining the virtual method table can become error-prone.
  • Performance-critical Situations: When looking up methods and avoiding the verbosity of if statements or switch cases, this approach can be more efficient, especially if the types are accessed frequently and consistently. Dictionary lookups are generally fast, allowing for quicker access to the corresponding fee calculation methods.

Dynamic Types

With additional modification of IPayment interface and Payment class we can allow it to handle different fee calculations for various regions without requiring explicit type checks or a error-prone explicit virtual method table. This approach leverages C#’s dynamic types capabilities to allow flexibility in method resolution, effectively simulating double dispatch:

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

IPayment.cs

public abstract class Payment : IPayment
{
    public decimal CalculateFee(dynamic region)
    {
        return CalculateFeeForRegion(region);
    }
    
    protected abstract decimal CalculateFeeForRegion(Europe region);
    
    protected abstract decimal CalculateFeeForRegion(NorthAmerica region);
    
    private decimal CalculateFeeForRegion(Region region)
    {
        throw new InvalidOperationException($"Unsupported region");
    }
}

Payment.cs

In this approach, the CalculateFee method uses the dynamic keyword for the region parameter. This allows the method to accept any type at runtime without requiring explicit type checks or casting. When the CalculateFee method is called, the runtime determines which CalculateFeeForRegion method to invoke based on the actual type of the region argument. If the region passed to the method matches one of the defined types (in this case, Europe or NorthAmerica), the corresponding implementation of CalculateFeeForRegion will be called. If an unsupported type is provided, the method will throw an InvalidOperationException.

Pros:

  • Simplicity: This approach simplifies the code by eliminating the need for explicit type checks or conditionals, allowing for more straightforward method invocations.
  • Flexibility: The use of dynamic types allows for greater flexibility, as any type can be passed in without prior checks, making it easier to extend the codebase with new region types in the future.

Cons:

  • Public API modification: The public API of payment classes needs to be modified to support dynamic input parameters, which may be unacceptable in some cases.
  • Performance overhead: The use of dynamic types introduces a slight performance overhead due to runtime type resolution, which can impact performance in high-frequency scenarios. If performance is a primary concern, it's advisable to avoid unnecessary use of dynamic.
  • Runtime errors: Errors related to unsupported region types will only be detected at runtime, which can lead to potential runtime exceptions if the code isn't carefully managed.
  • Maintainability: Since dynamic bypasses compile-time checks, we lose the benefits of compile-time type safety provided by the compiler. This can result in less maintainable code, as it becomes harder to determine what types are expected without closely examining the implementation details.

Best Use Cases:

  • Smaller scope: For smaller projects where maintainability is less of a concern, and performance isn't critical, this approach can provide a quick and easy way to manage varying region types.
  • Prototyping: It can be particularly useful during the early stages of development or prototyping, where flexibility of adapting to frequent changes and speed are prioritised over strict type checking.

The Visitor Pattern

The last approach I’d like to discuss is the visitor pattern. This pattern decouples the fee calculation logic from the payment class implementations, delegating it to the derived Region classes. This allows for easy introduction of new region types without modifying existing classes, facilitating better extensibility in your code.

Since the behaviour is extracted from the payment classes, we can safely remove our base Payment class. However, we need to modify the Region class to add three new abstract methods, which will be overridden in derived classes. Each of these methods is responsible for handling fee calculations based on the payment type used. This approach can be illustrated in the diagram below:

classDiagram
    class Region {
        <<abstract>>
        +decimal CalculateFeeForPayment(CardPayment payment)
        +decimal CalculateFeeForPayment(DigitalWalletPayment payment)
        +decimal CalculateFeeForPayment(BankTransferPayment payment)
    }

    class Europe {
        +decimal CalculateFeeForPayment(CardPayment payment)
        +decimal CalculateFeeForPayment(DigitalWalletPayment payment)
        +decimal CalculateFeeForPayment(BankTransferPayment payment)
    }

    class NorthAmerica {
        +decimal CalculateFeeForPayment(CardPayment payment)
        +decimal CalculateFeeForPayment(DigitalWalletPayment payment)
        +decimal CalculateFeeForPayment(BankTransferPayment payment)
    }

    Region <|-- Europe
    Region <|-- NorthAmerica

Region type hierarchy

Now we need to invoke these methods within the payment classes, so the implementation of each payment class will follow a similar pattern as shown in the CardPayment example here:

public class CardPayment : IPayment
{
    public decimal CalculateFee(Region region)
    {
        return region.CalculateFeeForPayment(this);
    }
}

CardPayment.cs

To better illustrate the execution flow, let's examine the sequence diagram that shows how the fee is calculated using the CardPayment and Europe region:

sequenceDiagram
    participant Client
    participant CardPayment
    participant Europe
    
    Client->>CardPayment: CalculateFee(region)
    CardPayment->>Europe: CalculateFeeForPayment(CardPayment)
    Europe-->>CardPayment: Return calculated fee
    CardPayment-->>Client: Return calculated fee

Fee calculation with CardPayment and Europe region

When the CalculateFee method is invoked on an IPayment implementation, it delegates the calculation to the appropriate method in the Region class based on its type. Specifically, the CalculateFee method calls the CalculateFeeForPayment method on the Region object, passing itself as a parameter. This design enables the Region class to manage fee calculations for various payment types, effectively achieving double dispatch.

Pros:

  • Decoupling from payment: The Visitor Pattern separates the payment logic from the payment type logic, which in many cases can make the code cleaner and easier to manage.
  • Extensibility: Adding new regions can be done independently. For instance, introducing a new region involves implementing the necessary behaviour within its respective class without requiring changes to existing payment classes.
  • Readability: The logic becomes easier to follow since the fee calculation for each region and payment type is clearly defined in one place.
  • Compiler support for unsupported regions: One of the benefits of using the Visitor Pattern in our case is that it doesn't require explicit logic to handle unsupported regions within the payment classes. Instead, the design allows the compiler to enforce the handling of different region types, which can help catch errors at compile-time rather than runtime. This reduces the likelihood of runtime exceptions related to unsupported regions and enhances the robustness of the code.

Cons:

  • Complexity: It introduces additional complexity by requiring multiple classes and methods to manage interactions between types. This added level of indirection may initially make it challenging to understand the execution flow.
  • Maintenance: Adding new payment types requires modifying all existing region classes to implement new fee calculation methods, which can increase the maintenance burden.
  • Increased coupling: The Region classes become tightly coupled with the Payment classes, which can make it challenging to refactor or change the system later.
  • Extended public interface: The fee calculation methods need to be public to allow indirect invocation from clients of payment types. This requirement might not be acceptable in certain situations, as it exposes more of the class’s internal workings than desired, potentially leading to unintended usage or reliance on these methods.

Best Use Cases:

  • Specific hierarchies: When dealing with two distinct type hierarchies, the Visitor Pattern is well-suited for scenarios where one set remains stable (as in our case with payment types) while the other can change (allowing us to introduce new regions without modifying existing classes).
  • Complex logic: The Visitor Pattern is particularly effective when the logic for calculating fees is complex and varies significantly between different regions and payment types. This pattern enhances organisation by separating the fee calculation logic from the payment classes, allowing for clearer management of the various calculations and making it easier to extend the system in the future.

Summary

Double dispatch can be a challenging concept, but understanding how it works and when to use it is essential. Each approach discussed here provides a way to simulate double dispatch effectively, enabling us to navigate type hierarchies and model behaviour across two runtime types. Although double dispatch is not natively supported in C# (or in most modern object-oriented languages), various techniques help us achieve similar functionality. Evaluating our specific use case and considering the pros and cons of each approach is key. I hope this article aids you in that process, offering a few options to choose from to suit your specific needs.

The code examples discussed in this article are available on GitHub. You can find the complete implementation here.

Read more