Go's Inheritance Enigma: Exploring Unconvential Patterns


Preamble

So, before we dive in, let’s make sure we’re on the same page. I’m assuming you’ve had some run-ins with Go and interfaces before – they’re kind of a big deal here. Oh, and pointers – they’re like the secret sauce in Go programming, so having a good grasp of those will definitely come in handy. Ready? Let’s crack this inheritance enigma wide open!

Introduction

Transitioning to Go from more conventionally object-oriented counterparts like Java and C++ can be an eye-opening experience. We bring with us a solid understanding of interfaces and their utility, albeit framed within the paradigms of those languages. As highlighted in my previous piece, A Guide to Interfaces in Go, interfaces take center stage in Go, yet their full potential often eludes us due to preconceived notions.

This article sheds light on two lesser-known patterns that I’ve uncovered, significantly broadening Go’s adaptability in scenarios where inheritance might otherwise have been the go-to solution. While I’m certainly not the first to explore these patterns (I encourage you to peruse the “References” section for further reading), they tend to be overlooked, primarily because many developers approach Go with a constrained understanding of interface capabilities – a perspective I once shared.

Below, we delve into the “Mix-In Pattern” and the “Back-Reference Pattern.” As you explore these concepts, you may find them strikingly familiar, albeit under different monikers. Should you recognize them from your own coding adventures, I welcome your insights. As far as nomenclature goes, I’ve assigned these names based on their distinct characteristics, as these patterns have largely gone unnamed in the lexicon of Go programming.

The Mix-In Pattern

As you are probably aware, interfaces can be embedded into structs, as such:

type Payment interface {
	Pay() error
}

type Order struct {
	// Order-specific fields go here...
	Payment
}

In contrast to many other object-oriented languages like Java, where a class commonly asserts its implementation of an interface, offering precisely one implementation, Go’s approach reveals a notable degree of flexibility. Here is the approach in Java:

public class Order implements Payment {
	public error Pay() { ... }
}

If we aimed to have different implementations of Pay(), each using their own payment provider, then in Java we might have achieved this with subclasses, for example StripePayment, PayPalPayment etc, all of which inherit from a parent Payment class, and implementing the Pay() method in each of their distinctive ways:

Class diagram of Payment and subclasses

In Go, this pattern can be greatly simplified by simply having structs for StripePayment and PayPalPayment that both satisfy the Payment interface. The Order struct can then — through the use of an embedded type — assign the appropriate implementation to itself during instantiation:

type StripePayment struct {}

func (p *StripePayment) Pay() error {
	fmt.Println("Paying with Stripe")
	return nil
}

type PayPalPayment struct {}

func (p *PayPalPayment) Pay() error {
	fmt.Println("Paying with PayPal")
	return nil
}

type Order struct {
	// Order-specific fields go here...
 	Payment
}

func NewOrder(paymentOption string) *Order {
	var payment Payment
	if paymentOption == "PayPal" {
		payment = &PayPalPayment{}
	} else {
		payment = &StripePayment{}
	}
	return &Order{Payment: payment}
}

Callers can now call Order.Pay() and get the correct behavior, depending upon whether the PayPal or Stripe option was chosen:

order := NewOrder("PayPal")
err := order.Pay()
// etc.

Take note of how the assignment of payment to Order.Payment in NewOrder isn’t restricted solely to actions within the constructor. In fact, we have the liberty to “hot-swap” the implementation at any given moment — imagine, for instance, transitioning from PayPal to Stripe at the caller’s request. While such transitions may necessitate adjustments for orders already partially processed, the prospect remains entirely feasible.

This capacity to interchange one implementation with another embodies a form of mix-in pattern, wherein the payment functionality seamlessly integrates (“mixes-in”) with the embedding struct, Order, notwithstanding its distinct logic apart from Payment.

The true extent of flexibility becomes clearer when considering the possibility of varying implementations for disparate functionalities. For instance, we could introduce delivery functionality:

type Delivery interface {
	Deliver() error
}

We can then implement delivery functionality for DHL and FedEx (among others), and embed them into Order in a similar manner:

type Order struct {
	// Order-specific fields go here...
	Payment
	Delivery
}

type DHLDelivery struct {}

func (d *DHLDelivery) Deliver() error {
	fmt.Println("Delivering with DHL!")
	return nil
}

type FedexDelivery struct {}

func (f *FedexDelivery) Deliver() error {
	fmt.Println("Delivering with Fedex!")
	return nil
}

func NewOrder(paymentOption, deliveryOption string) *Order {
	var payment Payment
	if paymentOption == "Stripe" {
		payment = &StripePayment{}
	} else {
		payment = &PayPalPayment{}
	}
	var delivery Delivery
	if deliveryOption == "DHL" {
		delivery = &DHLDelivery{}
	} else {
		delivery = &FedexDelivery{}
	}
	return &Order{
		Payment:        payment,
		Delivery:       delivery,
	}
}

We could even use the order status to determine if the order had already been delivered, and if so, then we would swap Order.Delivery with an implementation that we might call UndeliverableDelivery, which would simply return an error:

type UndeliverableDelivery struct{}

func (u *UndeliverableDelivery) Deliver() error {
	return errors.New("Order has already been delivered!")
}

Of course, for a simple example like this, we could just be pragmatic and use a switch statement when picking a delivery mechanism, directly on Order:

func (o *Order) Deliver() error {
	switch order.deliveryOption {
		case "DHL": return &DHLDelivery{}.Deliver()
		case "FedEx": return &FedExDelivery{}.Deliver()
		default: return errors.New("Order is undeliverable!")
	}
}

Therefore, when considering the Mix-In pattern, keep in mind that it should justify the added complexity. In the example provided earlier with payments and deliveries, envision broader functionalities beyond the single-method approach. For payments, this might entail features like credit card addition and validation, refund processing, and more. Similarly, for deliveries, functionalities could extend to inventory management, pickup point identification, and the like.

Mix-In and the Facade Pattern

In Go, it’s important to recall that we have the ability to override a method call to an embedded type simply by defining a method with an identical signature on the type performing the embedding. This concept is commonly referred to as “shadowing,” mirroring the behavior observed when declaring a variable with the same name as another existing within an “outer” scope:

func doSomething() error {
	var err error
	myFunc := func() error {
		err := anotherFunctionCall() // inner err shadows outer err
		return err
	}
	err = myFunc()
}

When employing embedding, the same concept can look as follows, utilizing our previously discussed example:

type Payment interface{
	Pay() error
}

type Order struct {
	Payment
}

func main() {
	order := &Order{Payment: &PayPalPayment{}}
	order.Pay()
}

The invocation of Order.Pay is initially forwarded to Payment.Pay due to the embedding of Payment. However, this changes if we define a method with identical signature directly on Order:

type Order struct {
	Payment
}

func (o *Order) Pay() error {
	// I have the same signature as Payment.Pay and will therebefore be called instead
}

We can take advantage of this to provide functionality that should happen just before, or just after Payment.Pay is called:

type Order struct {
	Payment
}

func (o *Order) Pay() error {
	// Do something first, such as recalculating the order total
	Payment.Pay()
	// Do something afterwards, such as changing the order state
}

What’s particularly elegant about this approach is that you’re only required to shadow the methods where additional behavior is necessary. The remainder will seamlessly continue to have their calls forwarded as expected. For instance, if Payment includes a Refund method that isn’t shadowed by an Order.Refund implementation, then invoking Payment.Refund would proceed directly without any interference.

Interlude

While the Mix-In Pattern offers pinpoint hot-swappable implementations, it does carry a significant limitation: Mix-Ins are unable to alter the struct into which they are embedded.

Why would that be useful? Imagine the scenario where a payment has been carried out by StripePayment:

func (p *StripePayment) Pay() error {
	fmt.Println("Performing payment with Stripe!")
	return nil
}

Maybe during the order calculation, additional expenses like shipping costs and taxes were factored in, and now we aim to refresh Order with the updated total:

func (p *StripePayment) Pay() error {
	fmt.Println("Performing payment with Stripe!")
	// Now I want to set Order.OrderTotal = newOrderTotalIncShippingAndTaxes
	return nil
}

Maybe there’s a need to update the order status to indicate payment completion or execute other functionalities implemented within Order. This is precisely where the Back-Reference Pattern becomes indispensable.

The Back-Reference Pattern

To address the issue highlighted in the interlude, we can equip StripePayment and PayPalPayment with a back-reference pointing to Order. Essentially, this involves incorporating a pointer, but there are two distinct approaches:

  1. Discrete Implementation: If the implementation is tailored, meaning Payment functionalities solely interact with an Order and nothing beyond, a pointer to Order suffices.
  2. Dynamic Implementation: In scenarios where the implementation is more fluid, such as Payment functionalities collaborating with various types like CostOrder, ZeroCostOrder, DiscountedOrder, each with unique implementations, a back-reference via an interface encapsulating the required functionality is preferred.

Let’s delve into both solutions.

Back-Reference via pointer

The best way to achieve this, is to initialize the back-reference together with the embedded struct. For clarity, we’ll leave out the Delivery functionality and focus on Payment:

type PayPalPayment struct {
	order *Order // This is our back-reference
}

func NewPayPalPayment(o *Order) *PayPaylPayment {
	return &PayPalPayment{order: o}
}

// A similar implementation exists for StripePayment

func NewOrder(paymentOption string) *Order {
	order := &Order{}

	if paymentOption == "Stripe" {
		order.payment = NewStripePayment(order)
	} else {
		order.payment = NewPayPalPayment(order)
	}

	return order
}

func main() {
	order := NewOrder("paypal")
}

Notice how we had to use a constructor for PayPalPayment, since you can’t otherwise assign to payment.order with it being an interface and all. But a type assertion could also have solved that.

With the back-reference in place, PayPalPayment and StripePayment are free to modify Order after having carried out their respective goals of ensuring that we earn some money on the order. As an example:

func (p *PayPalPayment) Pay() error {
	// Perform payment
	newOrderTotal, err := paypal.PayForTheDarnThing(p.order.Items) // Or whatever we need to pass here

	// Error handling goes here

	// Update order total:
	p.order.OrderTotal = newOrderTotal
	return nil // All ok
}

Do take note that we can only access exported fields of Order unless Payment and Order share the same package.

Back-Reference via interface

Implementing a back-reference via interface is similar to using pointers, but we have to think more carefully about what needs to go into the interface. From the above, it is clear that we need access to order items and order total, so let’s declare an interface for that:

type OrderUpdater interface {
	GetItems() []Item
	SetOrderTotal(newValue int)
}

And let’s use it in PayPalPayment:

type PayPalPayment struct {
	order OrderUpdater // This is our back-reference
}

func NewPayPalPayment(o *Order) *PayPaylPayment {
	return &PayPalPayment{order: o} // We can assign Order here because it satisfies OrderUpdater
}

// A similar implementation exists for StripePayment

func NewOrder(paymentOption string) *Order {
	order := &Order{}

	if paymentOption == "Stripe" {
		order.payment = NewStripePayment(order) // Pass Order so StripePayment has it as a backref
	} else {
		order.payment = NewPayPalPayment(order) // Pass Order so PayPalPayment has it as a backref
	}

	return order
}

func main() {
	order := NewOrder("paypal")
}

The Pay method is similar to before, except that now we are interacting with Order via method calls:

func (p *PayPalPayment) Pay() error {
	// Perform payment
	newOrderTotal, err := paypal.PayForTheDarnThing(p.order.GetItems())

	// Error handling goes here

	// Update order total:
	p.order.SetOrderTotal(newOrderTotal)
	return nil // All ok
}

This implementation also communicates very clearly to the Payment implementations to which degree they are allowed to interact with Order, via a well-defined interface.

The Order struct with mix-ins and back-references

The diagram above shows the Order struct and its mix-ins and their back-references. The dotted boxes represent the interfaces that are satisfied by their respective implementations. The background colors represent compatibility in terms of the interfaces and the dashed arrow shows a back-reference (pointer) from the implementation back to the embedding struct.

Code Example

A variation over the examples and implementations in this write-up, which showcases the utilization of both back-references via pointers (for the Delivery types) and via interfaces (for the Payment types), is provided.

You’re encouraged to experiment with the example in the Go Playground to delve into the implementation specifics.

Conclusion

We have explored how two very powerful features in Go, namely pointers and interfaces, can be combined to solve “Go’s inheritance enigma”.

The Mix-In Pattern leverages embedded structs, or alternatively, non-embedded structs using a facade pattern, mirroring the polymorphic behavior typically associated with subclasses in other languages via dynamic dispatch.

The Back-Reference Pattern employs pointers within embedded structs, directing back to the struct embedding them. This facilitates the sharing of state and data, whether through strongly typed pointers or interfaces delineating clear boundaries.

The fusion of these patterns affords all the benefits of inheritance, sidestepping its drawbacks through the realm of composition.

References