Understanding the Limitations of Single Dispatch in C#
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:
The following code shows a concrete implementation of IPayment
for handling card payments, which covers both credit and debit cards:
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.:
And finally, the last supported payment type models bank transfers:
The three implementations can be visualised on the diagram:
The fee depends solely on the type of the payment we're dealing with and can be represented as a simple, one-dimensional mapping:
And here we have a very simple client code, which focuses on calculating fee based on the provided payment type:
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:
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:
Let's attempt to model this. The updated interface for IPayment
would look like this:
So, the CardPayment
implementation could be structured as follows:
However, that approach would result in a compile error:
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:
Here’s an example of the CardPayment
implementation:
No compiler error this time, but when we execute the following client code:
we encounter a runtime error:
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!