How to Simulate Double Dispatch in C#
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:
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:
And a concrete payment type class, such as DigitalWalletPayment
, would look like this:
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 moreif
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:
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 orswitch
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
CalculateFee
with 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 orswitch
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:
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:
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:
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:
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 thePayment
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.