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:
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:
- Discrete Implementation: If the implementation is tailored, meaning
Payment
functionalities solely interact with anOrder
and nothing beyond, a pointer toOrder
suffices. - Dynamic Implementation: In scenarios where the implementation is more fluid, such as
Payment
functionalities collaborating with various types likeCostOrder
,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 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
- The Power of Struct Embedding and Interfaces in Golang, Anthony GG
- True Code Reuse in Go, Jesse Duffield
- The State Pattern, Refactoring Guru
- A Guide to Interfaces in Go, Martin Kock