Part 2: Methods and Interfaces

Martin Sulzmann

Overview

The topic of "types" is now covered by a separate set of notes.

Methods on named types

We can define methods on named types where we can use the "dot" notation to call methods.

Named types

type rectangle struct {
    length int
    width  int
}

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

struct {
    length int
    width  int
}

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

Method definitions

func (r rectangle) area() int {
    return r.length * r.width
}

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.

Method calls

We call methods by using the "dot" notation.

r1.area()

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.

Methods and call-by reference

func (r *rectangle) scale(s int) {
    r.length = r.length * s
    r.width = r.width * s
}

The * indicates that we pass r to scale by reference. Thus, the update is globally visible.

In Go, the compiler inserts the & operator to build a reference to r.

r1.scale(3)
(&r1).scale(3)

The above statements are equivalent. Go will automatically perform the conversion.

Complete example

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())

}

Overloaded methods

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
}

Limitations

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.

Structs - Further stuff

Method overloading + Interfaces

Interface

Interfaces are contracts between expected behavior and actual implementation. In Go, we can build a common interface for overloaded methods. Each value of the interface type must support the methods specified by the interface.

type shape interface {
    area() int
}

The receiver is left implicit in the interface declaration. In general, an interface may consist of several (overloaded) methods. We can also extend an existing interface.

type shapeSuper interface {
    shape
    scale()
}

The order among shape and scale is not relevant. The interface shapeScale supports the scale and the area method.

Interfaces are types and values of an interface type must support the declared methods.

Consider the following function definitions.

func shapeTwo(sh1, sh2 shape) int {
    return sh1.area() + sh2.area()
}

func shapeSuperTwo (sh1, sh2 shapeSuper) int {
    sh1.scale(5)
    sh2.scale(3)
    return sh1.area() + sh2.area()  
}

Recall that rectangle supports scale and area but square only supports area. Hence, arguments for shapeTwo can either be values of type rectangle or square whereas arguments can only be values of type rectangle (because square does not support scale).

The function shapeTwo subsumes the four earlier definitions we have seen.

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()
}

We can say that shapeTwo is a generic function because shapeTwo can be applied on values of different types.

For example, consider the following.

    var r1 rectangle = rectangle{1, 2}
    var s1 square = square{3}

    x := shapeTwo(r1, s1) // applied on (rectangle, square)
    y := shapeTwo(s1, s1) // applied on (square, square)

Variadic functions in Go

func shapes(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.

// Expects a list of shapes (slice).
// Pretty much the same compared to shapes.
// Only difference is that we first need to create a slice to call shapes2.
func shapes2(shs []shape) int {
    var a int = 0
    for _, elem := range shs {
        a = a + elem.area()
    }
    return a
}

Short summary

Complete example

package main

import "fmt"

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
}

type shape interface {
    area() int
}

func shapeTwo(sh1, sh2 shape) int {
    return sh1.area() + sh2.area()
}


// Variadic function
func shapes(shs ...shape) int {
    var a int = 0
    for _, elem := range shs {
        a = a + elem.area()
    }
    return a
}


// Expects a list of shapes (slice).
// Pretty much the same compared to shapes.
// Only difference is that we first need to create a slice to call shapes2.
func shapes2(shs []shape) int {
    var a int = 0
    for _, elem := range shs {
        a = a + elem.area()
    }
    return a
}


// extend the interface

type shapeSuper interface {
    scale(int)
    shape
}

func shapeSuperTwo (sh1, sh2 shapeSuper) int {
    sh1.scale(5)
    sh2.scale(3)
    return sh1.area() + sh2.area()  
}

func (s *square) scale(x int) {
    s.length = s.length * x
}

func namedTypes() {
    var r rectangle
    var r1 struct {
        length int
        width  int
    }

    // r = r1  // NOT OK
    r = (rectangle)(r1) // OK cast required

    fmt.Printf("%d", r.length)
}

func main() {
    var r1 rectangle = rectangle{1, 2}
    var s1 square = square{3}

    fmt.Printf("%d \n", r1.area()+s1.area())

    fmt.Printf("%d \n", shapeTwo(r1, s1))

    fmt.Printf("%d \n", shapes(r1, r1, s1, s1, s1))

    shs := []shape{r1, r1, s1, s1, s1}

    fmt.Printf("%d \n", shapes2(shs))

    fmt.Printf("%d \n", shapeSuperTwo(&s1, &s1))
    // scale expects a reference as argument,
    // therefore we need to pass in s1 by reference.
    // Building the reference within shapeSuperTwo is not sufficient.   
        
    
}

"Any" Interface

    interface{}
Any interface. Similar to the type `Object` in Java.

We can perform some run-time type cast.
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
    r, ok := anything.(rectangle)
`ok` equals false in case the cast fails

* BTW, Go automatically performs a `break` after each case.

* Complete example
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})

}

Abstract data types via interfaces

We build an abstract data type Set.

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())
}

Summary

Traditional OO approach

// 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(); }
}

Go's approach

Method overloading.

Interfaces to write polymorphic programs.

Go does not suppoert generics (a.k.a. parametric polymorphism).

Interfaces + overloaded methods can be used to mimic algebraic data types + pattern matching. See extended regular expression example.

Expression Problem

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.