Martin Sulzmann
Methods
Interfaces
Structural subtyping
Go’s structural subtyping versus Java’s nominal subtyping
Translation scheme for structural subtyping
Making use of methods/interfaces to express
abstract types
algebraic data types with pattern matching
We can define methods on named types where we can use the “dot” notation to call methods.
Named types are types defined via type
. Via the
type
keyword we introduce a (fresh) name for an existing
type.
Structurally rectangle
and the (unamed) type
are the same but rectangle
is a new type.
Hence, we cannot assign a variable of the above (unamed) type to a
variable of type rectangle
(and vice versa).
var r rectangle
var r1 struct {
length int
width int
}
// r = r1 // NOT OK
r = (rectangle)(r1) // OK cast required
The named type argument preceeds all other arguments (in this
example, area has no further arguments). There is no self
or this
. Struct values are always referenced by name.
We call methods by using the “dot” notation.
In the above definition of method area
on named type
rectangle
, the argument r
is passed to
area
as a value. We must use call-by reference if we wish
to update r
’s field values.
The *
indicates that we pass r
to
scale
by reference. Thus, the update is globally
visible.
In Go, the compiler inserts the *
and &
operator automatically. For example,
The above statements are equivalent. Go will automatically perform the conversion.
package main
import "fmt"
type rectangle struct {
length int
width int
}
func (r rectangle) area() int {
return r.length * r.width
}
func (r *rectangle) scale(s int) {
r.length = r.length * s
r.width = r.width * s
}
func main() {
var r1 rectangle = rectangle{1, 2}
var r2 rectangle = rectangle{width: 2, length: 1}
r3 := rectangle{width: 2, length: 1}
r3.scale(3)
fmt.Printf("%d \n", r1.area()+r2.area()+r3.area())
}
Go supports method overloading where method dispatch is based on the “receiver” type. The receiver is simply the object on which the method is called.
type rectangle struct {
length int
width int
}
type square struct {
length int
}
func (r rectangle) area() int {
return r.length * r.width
}
func (r square) area() int {
return r.length * r.length
}
Suppose we have several geometric objects (such as rectangle and
square). Each geometric object supports the area
method.
We wish to sum up the area of an arbitrary object. Our current best bet is to enumerate all the cases.
func sum1(r1 rectangle, r2 rectangle) int {
return r1.area() + r2.area()
}
func sum2(r rectangle, s square) int {
return r.area() + s.area()
}
func sum3(s square, r rectangle) int {
return r.area() + s.area()
}
func sum4(s1 square, s2 square) int {
return s1.area() + s2.area()
}
This is rather tedious. There is lots of code duplication and the
programmer needs to manually select the appropriate “sum” function. As
we will see shortly, interfaces
allows us to provide for a
common interface to objects that support common methods.
Interface:
Collection of methods that share the same receiver
Interfaces are types
Structural subtyping:
Named type s
is a structural subtype of interface
t
if s
implements all methods as declared by
t
Interface s
is a structural subtype of interface
t
if s
declares all of t
’s
methods 3 We will write s <= t
to denote the structural
subtype relation among s
and t
.
Subtyping principle:
e
is of type s
and
s <= t
then e
can be used in any context
where tye type t
is expectedThe receiver is left implicit in the interface declaration.
Interfaces are types.
What are possible values of type shape
?
Any value of type s
that is a structural subtype of
shape
? Any two values passed to the sumArea
function must s
Recall that rectangle
and square
both
implement the area
method.
func (r rectangle) area() int {
return r.length * r.width
}
func (s square) area() int {
return s.length * s.length
}
Hence, values of type rectangle
and square
are also of the interface type shape
. That is, we find
rectangle <= shape
square <= shape
For example, consider the following.
var r rectangle = rectangle{1, 2}
var s square = square{3}
x2 := sumArea(r, s) // applied on (rectangle, square)
x2b := sumArea(r, r) // applied on (rectangle, rectangle)
fmt.Printf("%d %d \n", x2, x2b)
We say that sumArea
is a generic function
because sumArea
can be applied on values of different
types.
An interface may consist of several (overloaded) methods. We can also extend an existing interface.
Here is a function that makes use of the extended shape
interface.
func sumAreaScaleBefore(n int, x, y shapeExt) int {
x.scale(n)
y.scale(n)
return x.area() + y.area()
}
Can we call sumAreaScaleBefore
with rectangles and
squares?
Recall the earlier method definitions.
func (r rectangle) area() int { // A1
return r.length * r.width
}
func (s square) area() int { // A2
return s.length * s.length
}
func (r *rectangle) scale(x int) { // S1
r.length = r.length * x
r.width = r.width * x
}
func (s *square) scale(x int) { // S2
s.length = s.length * x
}
What about the following?
This will not compile and yields a type error.
Neither rectangle
nor square
implement
the shapeExt
interface
That is, neither rectangle
nor square
are a structural subtype of shapeExt
The following works!
Why?
We can argue that *rectangle <= shapeExt
and
*square <= shapeExt
for the following reason
*rectangle
implements the scale
method,
see S1
rectangle
implements the area
method,
see A1
Any value receiver definition implies a pointer receiver.
Hence, *rectangle
implements the area
method as well.
Hence, *rectangle <= shapeExt
Regarding 3. Consider
The above method call will be transformed to
Hence, we can argue that *rectangle
(pointer to
rectangle
) the area
method as well.
Among interfaces, the subtype relation is structural. Hence, the following interface declarations are all equivalent.
type shapeExt interface {
shape
scale(int)
}
type shapeExt2 interface {
scale(int)
shape
}
type shapeExt3 interface {
scale(int)
area() int
}
type shapeExt4 interface {
area() int
scale(int)
}
package main
import "fmt"
// Example
type rectangle struct {
length int
width int
}
type square struct {
length int
}
func (r rectangle) area() int {
return r.length * r.width
}
func (s square) area() int {
return s.length * s.length
}
func (r *rectangle) scale(x int) {
r.length = r.length * x
r.width = r.width * x
}
func (s *square) scale(x int) {
s.length = s.length * x
}
type shape interface {
area() int
}
type shapeExt interface {
shape
scale(int)
}
func sumArea(x, y shape) int {
return x.area() + y.area()
}
func sumAreaScaleBefore(n int, x, y shapeExt) int {
x.scale(n)
y.scale(n)
return x.area() + y.area()
}
func test() {
var r rectangle = rectangle{1, 2}
var s square = square{3}
var rPtr *rectangle = &r
x0 := rPtr.area()
// (&rPtr).area()
fmt.Printf("%d \n", x0)
x1 := r.area() + s.area()
fmt.Printf("%d \n", x1)
x2 := sumArea(r, s) // applied on (rectangle, square)
x2b := sumArea(r, r) // applied on (rectangle, rectangle)
fmt.Printf("%d %d \n", x2, x2b)
pt := &r
x3 := pt.area()
// Implicit conversion to
// (*pt).area()
//
// Hence, any "value" receiver also implies the corresponding "pointer" receiver.
fmt.Printf("%d \n", x3)
// x3 := sumAreaScaleBefore(3, r, s)
//
// "rectangle does not implement shapeExt (scale method has pointer receiver)"
// same applies to square
x4 := sumAreaScaleBefore(3, &r, &s)
// Works because any method definition for a value,
// implies a method definition for the pointer.
fmt.Printf("%d \n", x4)
}
func main() {
test()
}
// Java example (pseudo-code)
//////////////////////////////////////
class Shape {
int area();
}
class ShapeExt extends Shape {
scale(int);
}
class ShapeExt2 extends Shape {
scale(int);
}
From the above class declarations we derive
ShapeExt <= Shape
ShapeExt2 <= Shape
where <=
denotes the subtype relation.
This form of subtyping is referred to as nominal subtyping. The subtype relations are explicitly declared via class hierarchies.
The “any” interface. For any type t
we find that
t <= interface{}
. This is similar to the type
Object
in Java.
A value of type interface{}
can be anything. So, we need
to perform some run-time type casts to access the actual value.
func any(anything interface{}) {
switch v := anything.(type) {
case int:
fmt.Printf("some int %d \n", v)
case rectangle:
fmt.Println(v)
r := anything.(rectangle)
fmt.Printf("length = %d, width = %d \n", r.length, r.width)
default:
fmt.Println("don't know")
}
}
We can also cast to a specific type, see
anything.(rectangle)
Such a cast may fail
We can catch failure via
ok
equals false in case the cast fails
BTW, Go automatically performs a break
after each
case.
Why would you need interface{}
in Go (or
Object
in Java)?
Writing generic code (for example “collections”)
But this is dangerous cause we need run-time type casts!
Indeed, once the language supports “generic types” there is no
need for interface{}
(or Object
)
However, interface{}
(or Object
) may
come up in the translation (compilation) of programs.
package main
import "fmt"
type rectangle struct {
length int
width int
}
func any(anything interface{}) {
switch v := anything.(type) {
case int:
fmt.Printf("some int %d \n", v)
case rectangle:
fmt.Println(v)
r := anything.(rectangle)
fmt.Printf("length = %d, width = %d \n", r.length, r.width)
default:
fmt.Println("don't know")
}
}
func main() {
any(1)
any(rectangle{1, 2})
}
Go supports overloaded methods, interfaces and structural subtyping.
We wish to translate Go programs into a more primitive language (= Go without method definitions).
Each method definition can be uniquely identified via
the method name
the type of the receiver
Translating method definitions is easy. Simply introduce unique function names.
For our running example, we find the following.
func area_Rec(r rectangle) int {
return r.length * r.width
}
func area_Sq(s square) int {
return s.length * s.length
}
How to translate generic functions that make use of interfaces? Recall
Run-time method lookup
Values carry type information
Query the type of values to select the appropriate method
Structural subtyping translates to
t <= interface{}
Dictionary translation (inspired by Haskell’s type class translation scheme)
Interface values represented as a pair
The actual value plus the dictionary of method definitions
Structural subtyping translates to the construction of interface values
Both translation methods rely on interface{}
!
func area_Lookup(x interface{}) int {
var y int
switch v := x.(type) {
case square:
y = area_Sq(v)
case rectangle:
y = area_Rec(v)
}
return y
}
func sumArea_Lookup(x, y interface{}) int {
return area_Lookup(x) + area_Lookup(y)
}
type shape_Value struct {
val interface{}
area func(interface{}) int
}
func sumArea_Dict(x, y shape_Value) int {
return x.area(x.val) + y.area(y.val)
}
val
is the receiver value
area
is the matching method definition (represented
as a function where the receiver is the first argument)
area_Rec_Wrapper := func(v interface{}) int {
return area_Rec(v.(rectangle))
}
area_Sq_Wrapper := func(v interface{}) int {
return area_Sq(v.(square))
}
rDictShape := shape_Value{r, area_Rec_Wrapper}
sDictShape := shape_Value{s, area_Sq_Wrapper}
sumArea_Dict(rDictShape, sDictShape)
Wrapper functions are needed for the following reason.
area_Rec
has type
func(rectangle) int
We need to store area_Rec
in the “area” dictionary
entry which has type func(interface{}) int
We cast area_Rec
to the approrpriate type
package main
import "fmt"
// Example
type rectangle struct {
length int
width int
}
type square struct {
length int
}
func (r rectangle) area() int {
return r.length * r.width
}
func (s square) area() int {
return s.length * s.length
}
func (r *rectangle) scale(x int) {
r.length = r.length * x
r.width = r.width * x
}
func (s *square) scale(x int) {
s.length = s.length * x
}
type shape interface {
area() int
}
type shapeExt interface {
shape
scale(int)
}
func sumArea(x, y shape) int {
return x.area() + y.area()
}
func sumAreaScaleBefore(n int, x, y shapeExt) int {
x.scale(n)
y.scale(n)
return x.area() + y.area()
}
func test() {
var r rectangle = rectangle{1, 2}
var s square = square{3}
x1 := r.area() + s.area()
fmt.Printf("%d \n", x1)
x2 := sumArea(r, s)
fmt.Printf("%d \n", x2)
pt := &r
x3 := pt.area()
// Implicit conversion to
// (*pt).area()
//
// Hence, any "value" receiver also implies the corresponding "pointer" receiver.
fmt.Printf("%d \n", x3)
// x3 := sumAreaScaleBefore(3, r, s)
//
// "rectangle does not implement shapeExt (scale method has pointer receiver)"
// same applies to square
x4 := sumAreaScaleBefore(3, &r, &s)
fmt.Printf("%d \n", x4)
}
// Introducing unique function names for overloaded methods
func area_Rec(r rectangle) int {
return r.length * r.width
}
func area_Sq(s square) int {
return s.length * s.length
}
// "value" method implies "pointer" method
func area_RecPtr(r *rectangle) int {
return area_Rec(*r)
}
func area_SqPtr(s *square) int {
return area_Sq(*s)
}
func scale_RecPtr(r *rectangle, x int) {
r.length = r.length * x
r.width = r.width * x
}
func scale_SqPtr(s *square, x int) {
s.length = s.length * x
}
// Run-time method lookup
func area_Lookup(x interface{}) int {
var y int
switch v := x.(type) {
case square:
y = area_Sq(v)
case rectangle:
y = area_Rec(v)
}
return y
}
func sumArea_Lookup(x, y interface{}) int {
return area_Lookup(x) + area_Lookup(y)
}
func test_Lookup() {
var r rectangle = rectangle{1, 2}
var s square = square{3}
x1 := area_Rec(r) + area_Sq(s)
fmt.Printf("%d \n", x1)
x2 := sumArea_Lookup(r, s)
// rectangle <= interface{}
// square <= interface{}
fmt.Printf("%d \n", x2)
}
// Dictionary translation
type shape_Value struct {
val interface{}
area func(interface{}) int
}
type shapeExt_Value struct {
val interface{}
area func(interface{}) int
scale func(interface{}, int)
}
// shapExt <= shape
func fromShapeExtToShape(x shapeExt_Value) shape_Value {
return shape_Value{x.val, x.area}
}
func sumArea_Dict(x, y shape_Value) int {
return x.area(x.val) + y.area(y.val)
}
func sumAreaScaleBefore_Dict(n int, x, y shapeExt_Value) int {
x.scale(x.val, n)
y.scale(y.val, n)
return x.area(x.val) + y.area(y.val)
}
func test_Dict() {
var r rectangle = rectangle{1, 2}
var s square = square{3}
// 1. Plain method calls
x1 := area_Rec(r) + area_Sq(s)
fmt.Printf("%d \n", x1)
x2 := sumArea(r, s)
fmt.Printf("%d \n", x2)
pt := &r
x3 := area_Rec(*pt)
// Implicit conversion from pointer to value
fmt.Printf("%d \n", x3)
// x3 := sumAreaScaleBefore(3, r, s)
//
// "rectangle does not implement shapeExt (scale method has pointer receiver)"
// same applies to square
// 2. Calling sumArea
// Wrapper functions are needed for the following reason.
// (a) area_Rec has type func(rectangle) int
// (b) We need to store area_Rec in the "area" dictionary entry which has type func(interface{}) int
// (c) We cast area_Rec to the approrpriate type
area_Rec_Wrapper := func(v interface{}) int {
return area_Rec(v.(rectangle))
}
area_Sq_Wrapper := func(v interface{}) int {
return area_Sq(v.(square))
}
rDictShape := shape_Value{r, area_Rec_Wrapper}
sDictShape := shape_Value{s, area_Sq_Wrapper}
x4 := sumArea_Dict(rDictShape, sDictShape)
fmt.Printf("%d \n", x4)
// 3. Calling sumAreaScaleBefore
area_RecPtr_Wrapper := func(v interface{}) int {
return area_RecPtr(v.(*rectangle))
}
area_SqPtr_Wrapper := func(v interface{}) int {
return area_SqPtr(v.(*square))
}
scale_RecPtr_Wrapper := func(v interface{}, x int) {
scale_RecPtr(v.(*rectangle), x)
}
scale_SqPtr_Wrapper := func(v interface{}, x int) {
scale_SqPtr(v.(*square), x)
}
// Construct the appropriate interface values
rDictShapeExt := shapeExt_Value{&r, area_RecPtr_Wrapper, scale_RecPtr_Wrapper}
sDictShapeExt := shapeExt_Value{&s, area_SqPtr_Wrapper, scale_SqPtr_Wrapper}
x5 := sumAreaScaleBefore_Dict(3, rDictShapeExt, sDictShapeExt)
fmt.Printf("%d \n", x5)
// 4. Calling sumArea with a shapeExt value
x6 := sumArea_Dict(fromShapeExtToShape(rDictShapeExt), fromShapeExtToShape(sDictShapeExt))
fmt.Printf("%d \n", x6)
}
func main() {
test()
test_Lookup()
test_Dict()
}
func sumAreas(shs ...shape) int {
var a int = 0
for _, elem := range shs {
a = a + elem.area()
}
return a
}
We can define arguments with a variable number of arguments (of the
some type) and iterate over them via a for
loop.
In the above we don’t care (_
) about the index
position.
Variadic functions simply provide some syntactic sugar compared to using slices as arguments.
An abstract data type is defined in terms of its behavior. For example, operations performed on this type etc. Implementation details (of this type) are left abstract (hidden).
We build an abstract data type Set
via interfaces. The
below is an example where we use recursive interfaces.
Recursive means that the interface type appears in some type declaration
of some method.
package main
import "fmt"
type Set interface {
empty() Set
isEmpty() bool
insert(int) Set
}
type SetImpl []int
func (xs SetImpl) empty() Set {
return (SetImpl)([]int{})
}
func (xs SetImpl) isEmpty() bool {
return len(xs) == 0
}
func (xs SetImpl) insert(x int) Set {
ys := append(xs, x)
return (SetImpl)(ys)
}
// This is a function, not a method.
func mkEmptySet() Set {
xs := []int{}
return (SetImpl)(xs)
}
func main() {
// We consider sets of integers here
s := SetImpl{1, 2, 3}
fmt.Printf("%b", s.isEmpty())
}
Important.
Consider
We must apply the cast (SetImpl)([]int{})
.
[]int{}
is of type []int
but this type is
not compatible with Set
.
SetImpl
satisfies Set
and therefore
.
Hence, isEmpty
returns a value of type
Set
.
Algebraic data types are build by composition via other types. Examples are tuples and structs. Tuples and structs correspond to AND. For example, consider
The struct Point
consists of a value of type
int
and a value of type bool
.
Further examples of algebraic data types are sum types (also referred to as disjoint union, variant type). Values of sum types are created via distinct constructors. Hence, a sum type corresponds to OR. Languages with support for sum types usually allow for a form of pattern matching via which we can analyse and extract the data connected to each value case of a sum type.
Go has no direct support for sum types and pattern matching. However, we can emulate both with the help of interfaces/methods.
The maybe type (also kown as the option type is typical example of a sum type. The maybe type supports two value cases: Either nothing (representing failure) or just something (representing success). In Go, the maybe type can be represented as follows.
type Nothing struct{}
type Just struct {
val int
}
type Maybe interface {
isNothing() bool
isJust() bool
fromJust() (int, bool)
}
func (x Nothing) isNothing() bool {
return true
}
func (x Just) isNothing() bool {
return false
}
func (x Nothing) isJust() bool {
return false
}
func (x Just) isJust() bool {
return true
}
func (x Nothing) fromJust() (int, bool) {
return 1, false
}
func (x Just) fromJust() (int, bool) {
return x.val, true
}
We introduce named types Nothing
and Just
to represent the two cases (failure and success). The Maybe
interface and method instances for Nothing
and
Just
imply that Maybe
is a sum type with
constructors Nothing
and Just
. Pattern
matching in Go is emulated via overloaded method definitions.
As another example of a sum type, we consider linked lists.
type Nil struct{}
type Cons struct {
elem int
next List
}
type List interface {
len() int
}
func (x Nil) len() int { return 0 }
func (x Cons) len() int {
return 1 + x.next.len()
}
There two constructors. Nil
to create the empty list and
Cons
via which we add an element to an existing list. What
is interesting here is that in the type definition for Cons
we declare that next
is some List
where
List
is any type that supports the len
method.
Via algebraic data types we can describe context-free/EBNF grammars.
Consider the following simple expression language
N ::= ... | -1 | 0 | 1 | ...
E ::= N | E + E | E * E
and its representation in Go represented as an algebraic data type.
type Exp interface {
eval() int
}
type Num int
type Mult [2]Exp
type Plus [2]Exp
func (x Num) eval() int {
return (int)(x)
}
func (e Mult) eval() int {
n1 := e[0].eval()
n2 := e[1].eval()
return n1 * n2
}
func (e Plus) eval() int {
n1 := e[0].eval()
n2 := e[1].eval()
return n1 + n2
}
We not only represent the expression language in Go. In addition, we
also provide for some methods that operate on expressions. In the above,
we introduce an eval
method that evaluates expressions.
As we will discuss in more detail later (see syntax analysis), the above expression grammar represents an abstract representation of the syntactic structure of expressions. As we know from theoretical computer science, derivations that verify that an expression is well-formed (part of language described by the above grammar) can also be represented as trees. Hence, the representation of the expression grammar in Go allows us to represent abstract syntax trees (ASTs).
package main
import "fmt"
type Nothing struct{}
type Just struct {
val int
}
type Maybe interface {
isNothing() bool
isJust() bool
fromJust() (int, bool)
}
func (x Nothing) isNothing() bool {
return true
}
func (x Just) isNothing() bool {
return false
}
func (x Nothing) isJust() bool {
return false
}
func (x Just) isJust() bool {
return true
}
func (x Nothing) fromJust() (int, bool) {
return 1, false
}
func (x Just) fromJust() (int, bool) {
return x.val, true
}
func myDiv(x int, y int) Maybe {
if y == 0 {
return Nothing{}
}
return Just{x / y}
}
// Linked lists
type Nil struct{}
type Cons struct {
elem int
next List
}
type List interface {
len() int
}
func (x Nil) len() int { return 0 }
func (x Cons) len() int {
return 1 + x.next.len()
}
func main() {
// maybe
r, _ := myDiv(3, 2).fromJust()
fmt.Printf("\n %d", r)
// lists
list := Cons{1, Cons{2, Cons{3, Nil{}}}}
fmt.Printf("\n %d", list.len())
}
package main
import "fmt"
type Exp interface {
eval() int
}
type Num int
type Mult [2]Exp
type Plus [2]Exp
func (x Num) eval() int {
return (int)(x)
}
func (e Mult) eval() int {
n1 := e[0].eval()
n2 := e[1].eval()
return n1 * n2
}
func (e Plus) eval() int {
n1 := e[0].eval()
n2 := e[1].eval()
return n1 + n2
}
// Helper functions to build ASTs by hand
func number(x int) Exp {
return Num(x)
}
func plus(x, y Exp) Exp {
return (Plus)([2]Exp{x, y})
// The type Plus is defined as the two element array consisting of Exp elements.
// Plus and [2]Exp are isomorphic but different types.
// We first build the AST value [2]Exp{x,y}.
// Then cast this value (of type [2]Exp) into a value of type Plus.
}
func mult(x, y Exp) Exp {
return (Mult)([2]Exp{x, y})
}
func main() {
ex := plus(number(1), mult(number(2), number(3)))
fmt.Printf("\n %d", ex.eval())
}
object = struct + methods
Nominal subtype relations derived from class hierarchies
Inheritance (reuse of methods) and virtual methods as a means to write polymorphic programs
// OO sketch Java style
class Shape {
int area();
}
class Square : Shape {
int x;
int area() { return x * x; }
}
class Rectangle : Shape {
int x, y;
int area() { return x * y; }
}
// Virtual method resolution
int shapeTwo(Shape sh1, sh2) {
return sh1.area() + sh2.area()
}
Another example. AST representation and some virtual method for evaluation.
// OO sketch
class Exp {
int eval();
}
class Num : Exp {
int x;
Num(int y) { x = y ; }
int eval( return x; }
class Plus : Exp {
Exp left, right;
Plus(Exp x, y) { left = x; right = y; }
int eval() { return left.eval() + right.eval(); }
}
class Mult : Exp {
Exp left, right;
Plus(Exp x, y) { left = x; right = y; }
int eval() { return left.eval() * right.eval(); }
}
Method overloading.
Define your named types (structs, …).
Define (overloaded on the receiver) methods for named types.
Loosely coupled. Can easily add new structs and new methods.
Write generic code via interfaces and structural subtyping
Thanks to interfaces we can model abstract data types.
Interfaces + overloaded methods can be used to mimic algebraic data types + pattern matching.
Consider the above AST representation in Java. It is easy to add a new case such as subtraction.
class Sub : Exp {
Exp left, right;
Sub(Exp x, y) { left = x; right = y; }
int eval() { return left.eval() - right.eval(); }
}
There is no need to change any of the existing cases!
What about adding some new functionality? For example, a pretty print method. We will need to update all cases to include this new functionality.
In language like Haskell, it is easy to add new functionality (without affecting existing code). But once we add a new case, we need to change all existing functions that operate on these cases.
Challenge: Can we add new cases and new functionality without having to change (and therefore) recompile existing code?
This challenge is known as the Expression Problem. There is no straightforward solution to the Expression Problem in a language like Haskell, Java or Go. But there are encodings for the Expression Problem. We might come back to this topic later.