Tuesday, June 4, 2024

Golang - OOP - Attach methods


Before we go into the 'attach methods', consider the following example.


book.go
type book struct {
    title string
    price int
}

func printBook(b book) {
    fmt.Printf("Book: title=%q, price=%d\n", b.title, b.price)
}


game.go
type game struct {
    title string
    price int
}

func printGame(g game) {
    fmt.Printf("Game: title=%q, price=%d\n", g.title, g.price)
}


main.go
func main() {
    book1 := book{
      title: "book1",
      price: 10,
    }
    game1 := game{
      title: "game1",
      price: 50,
    }

    printBook(book1)
    printGame(game1)
}


Both the 'book' and 'game' types contain merely data and have no behaviors.

The separated package level functions 'printBook' and 'printGame' mainly use the relevant types, such as 'book' and 'game'.

Also, because Go does not support function overloading, we must declare various function names even if the functions have similar behavior. (Refer this article)


Can we link data (book type) and behaviors (printBook) together in Go, like with other OOP languages? (class + methods)?


Attach functions to types


We can change those functions into types to become behaviors (or we named them 'Method Set').

Format:
  func (receiver_name receiver_type) func_name(input_params) (result_params) {
// your code here
  }


'receiver' is 'syntactic sugar', and it is simply an input parameter that Go will pass automatically for us.

As a result, if we call the methods specified in the following format, Go will automatically send a copy of the type value to those type method sets.

Format:
type_value.func_name(input_params)


We can modify the previous example as shown below.

book.go
type book struct {
    title string
    price int
}

func (b book) print() {
    fmt.Printf("Book: title=%q, price=%d\n", b.title, b.price)
}


game.go
type game struct {
    title string
    price int
}

func (g game) print() {
    fmt.Printf("Game: title=%q, price=%d\n", g.title, g.price)
}


main.go
func main() {
    book1 := book{
      title: "book1",
      price: 10,
    }
    game1 := game{
      title: "game1",
      price: 50,
    }

    book1.print()
    game1.print()
}


We may use the same function name because they are now at the type level (different namespace) rather than the package level.

Also, we can invoke the method defined by the type directly without difficulty.

main.go
func main() {
    book1 := book{
      title: "book1",
      price: 10,
    }
    game1 := game{
      title: "game1",
      price: 50,
    }

    book.print(book1)
    game.print(game1)
}



To sum up, behind the scenes, a method is a function with a receiver as the first parameter.

    * We can use type to call the method set directly by passing the type value manually by us. 

    * Alternatively, we can call the method set by type value, and Go will assist in passing the type value as the first parameter to the method set.


Pointer Receiver



We have some troubles using Receiver to update type values.

book.go
type book struct {
    title string
    price int
}

func (b book) print() {
    fmt.Printf("Book: title=%q, price=%d\n", b.title, b.price)
}

func (b book) discount(amout int) {
    b.price -= amout
}


main.go
func main() {
    book1 := book{
      title: "book1",
      price: 10,
    }

    book1.print()
    book1.discount(5)

    book1.print()
}


Result:
  Book: title="book1", price=10
  Book: title="book1", price=10


Ideally, the price of book1 after adjustment should be 5, not 10.

The underlying cause is that the receiver is just a copy of the type value.

Any modifications to the copied value will not impact the original type value.

To overcome this issue, Go introduces a Pointer Receiver, which permits modifications to the original type value via memory addresses.

Format:
  func (receiver_name *receiver_type) func_name(input_params) (result_params) {
// your code here
  }


When invoking type methods set, we can use those two forms indicated below.

Format:
    // Option 1
    (&type_value).func_name(input_params)

    // Option 2 (Recommended. Go will take its address automatically
// if the method has a pointer receiver)
    type_value.func_name(input_params)


See the changed example below.

book.go
type book struct {
    title string
    price int
}

func (b book) print() {
    fmt.Printf("Book: title=%q, price=%d\n", b.title, b.price)
}

func (b *book) discount(amout int) {
    b.price -= amout
}


main.go
func main() {
    book1 := book{
      title: "book1",
      price: 10,
    }

    book1.print()

// Option 1
    (&book1).discount(5)

// Option 2
    // Can use this simple form since Go will help to transform
    // book1.discount(5)

    book1.print()
}


Result:
  Book: title="book1", price=10
  Book: title="book1", price=5



Receiver V.S. Pointer Receiver



If we are dealing with vast amounts of data, we should use a pointer receiver since it will refer to the actual data rather than creating a copy.

Take the following example: using pointer receivers is much faster than using regular receivers.

huge.go
type huge struct {
    data [1000000]string
}

func (h huge) print() {
    fmt.Printf("%p\n", &h)
}

func (h *huge) pointerPrint() {
    fmt.Printf("%p\n", h)
}


main.go
func main() {
    var huge huge

    for i := 0; i < 10; i++ {
      // huge.print()
      huge.pointerPrint()
    }
}


Result (Receiver):
  real    0m0.429s
  user    0m0.481s
  sys     0m0.211s


Result (Pointer Receiver):
  real    0m0.186s
  user    0m0.191s
  sys     0m0.143s  



Attach methods to almost all types



In the last part, we learnt how to attach methods to struct types.

Not only for that, but also Go allows you to attach methods to almost any type.

However, you should not use a pointer receiver for the following types because the passed-by value is already a memory address:

    slice, map, chan, and func

Let's try modifying the following example to attach methods to slice types.

game.go
type game struct {
    title string
    price int
}

// Add method to struct
func (g game) print() {
    fmt.Printf("Game: title=%q, price=%d\n", g.title, g.price)
}


main.go
func main() {
    game1 := game{
      title: "game1",
      price: 50,
    }

    game2 := game{
      title: "game2",
      price: 30,
    }

    var games []game
    games = append(games, game1, game2)

    for _, g := range games {
      // Call attached methods
      g.print()
    }
}



To begin, we may try attaching a method to []game as shown below, but this will result in a compile error because []game is an undefined type.

Ex:
func (l []game) print() {
for _, g := range l {
g.print()
}
}


Error:
  invalid receiver type []game


A type and its attached methods must be in the same package, and the unnamed(undefined) type does not belong to any package.

Then, let us use the defined type 'list' to achieve this.

Idea:
type list []game

func (l list) print() {
    for _, g := range l {
      g.print()
    }
}


We can also easily convert types if the underlying types are the same.

Idea:
// Convert []game to the list type
  list := list(games)

  // Call an attached method of the defined type ([]game)
  list.print()


The example with the final adjustments.

game.go

type game struct {
    title string
    price int
}

func (g game) print() {
    fmt.Printf("Game: title=%q, price=%d\n", g.title, g.price)
}


list.go

type list []game

func (l list) print() {
    for _, g := range l {
      g.print()
    }
}


main.go

func main() {
    game1 := game{
      title: "game1",
      price: 50,
    }

    game2 := game{
      title: "game2",
      price: 30,
    }

    var games []game
    games = append(games, game1, game2)

    // Convert []game to the list type
    list := list(games)

    // Call an attached method of the defined type ([]game)
    list.print()
}


The following is an example of attaching methods to an integer type.

The attached int method will return a string containing the $ symbol.

money.go:

type money int

func (m money) string() string {
    return fmt.Sprintf("$%d", m)
}


No comments:

Post a Comment