DEV Community

Cover image for Mastering Go: Unleashing the Potential of Methods, Interfaces, Generics, and Concurrency | Part - 3
Sahil Sojitra
Sahil Sojitra

Posted on • Edited on

Mastering Go: Unleashing the Potential of Methods, Interfaces, Generics, and Concurrency | Part - 3

In the world of programming languages, Golang (also known as Go) has gained significant popularity for its simplicity, efficiency, and robustness. With its unique syntax and powerful features, Golang offers developers a versatile toolkit to build scalable and high-performance applications. In this blog, we will dive deep into some of the key aspects of Golang syntax, namely , Methods, Interfaces, Generics & Concurrency. Let's explore each topic in detail:

Methods

  • Go does not have classes. However, you can define methods on types.
  • A method is a function with a special receiver argument. = The receiver appears in its own argument list between the func keyword and the method name.
  • In this example, the Abs method has a receiver of type Vertex named v.
package main

import (
    "fmt"
    "math"
)

type Vertex struct {
    X, Y float64
}

// Remember: a method is just a function with a receiver argument.
// Here's Abs written as a regular function with no change in functionality.

func Abs_func(v Vertex) float64 {
    return math.Sqrt(v.X*v.X + v.Y*v.Y)
}

func (v Vertex) Abs() float64 {
    return math.Sqrt(v.X*v.X + v.Y*v.Y)
}

func main() {
    v := Vertex{3, 4}
    fmt.Println(v.Abs())

    f := Vertex{12, 5}
    fmt.Println(Abs_func(f))
}
Enter fullscreen mode Exit fullscreen mode

Output:

5
13
Enter fullscreen mode Exit fullscreen mode
  • You can only declare a method with a receiver whose type is defined in the same package as the method. You cannot declare a method with a receiver whose type is defined in another package (which includes the built-in types such as int).
package main

import (
    "fmt"
    "math"
)

type MyFloat float64

func (f MyFloat) Abs() float64 {
    if f < 0 {
        return float64(-f)
    }
    return float64(f)
}

func main() {
    f := MyFloat(-math.Sqrt(2))
    fmt.Println(f)
    fmt.Println(f.Abs())
}
Enter fullscreen mode Exit fullscreen mode

Output

-1.4142135623730951
1.4142135623730951
Enter fullscreen mode Exit fullscreen mode

Pointer Receivers

  • Go allows the declaration of methods with pointer receivers. Pointer receivers have the syntax *T, where T is a type (not a pointer type itself, such as *int). These methods can modify the value to which the receiver points. In contrast, methods with value receivers operate on a copy of the original value.
  • For example, consider the Scale method defined on *Vertex. If the * is removed from the receiver declaration, the method will no longer be able to modify the original Vertex value. Pointer receivers are commonly used when methods need to modify their receiver.
  • Removing the * from the receiver declaration changes the behavior to operate on a copy of the value instead.
package main 

import (
    "fmt"
    "math"
)

type Vertex struct {
    X, Y float64
}

func (v Vertex) Abs() float64 {
    return math.Sqrt(v.X*v.X + v.Y*v.Y)
}

// Method With Pointer Receiver
func (v *Vertex) Scale(f float64) {
    v.X = v.X * f
    v.Y = v.Y * f
    fmt.Println(v.X,v.Y)
}

// Scale Function
func (v Vertex) Scale_(f float64) {
    v.X = v.X * f
    v.Y = v.Y * f
    fmt.Println(v.X,v.Y)
}

func main() {
    v := Vertex{3, 4}
    v.Scale(10)
    fmt.Println(v.X, v.Y)
    fmt.Println(v.Abs(),"\n")

    f := Vertex{3, 4}
    f.Scale_(10)
    fmt.Println(f.X, f.Y)
    fmt.Println(f.Abs())
}
Enter fullscreen mode Exit fullscreen mode

Output:

30 40
30 40
50 

30 40
3 4
5
Enter fullscreen mode Exit fullscreen mode

Methods and Pointer Indirection

  • you might notice that functions with a pointer argument must take a pointer:
var v Vertex
ScaleFunc(v, 5)  // Compile error!
ScaleFunc(&v, 5) // OK
Enter fullscreen mode Exit fullscreen mode
  • while methods with pointer receivers take either a value or a pointer as the receiver when they are called:
var v Vertex
v.Scale(5)  // OK
p := &v
p.Scale(10) // OK
Enter fullscreen mode Exit fullscreen mode

Consider the following example:

package main

import "fmt"

type Vertex struct {
    X, Y float64
}

func (v *Vertex) Scale(f float64) {
    v.X = v.X * f
    v.Y = v.Y * f
}

func ScaleFunc(v *Vertex, f float64) {
    v.X = v.X * f
    v.Y = v.Y * f
}

func main() {
    v := Vertex{3, 4}
    v.Scale(2)
    fmt.Println(v)
    ScaleFunc(&v, 10)
    fmt.Println(v,"\n")

    p := &Vertex{3, 4}
    p.Scale(2)
    fmt.Println(p)
    ScaleFunc(p,10)
    fmt.Println(p)
}
Enter fullscreen mode Exit fullscreen mode

Output:

{6 8}
{60 80} 

&{6 8}
&{60 80}
Enter fullscreen mode Exit fullscreen mode
  • The equivalent thing happens in the reverse direction.
  • Functions that take a value argument must take a value of that specific type:
var v Vertex
fmt.Println(AbsFunc(v))  // OK
fmt.Println(AbsFunc(&v)) // Compile error!
Enter fullscreen mode Exit fullscreen mode
  • while methods with value receivers take either a value or a pointer as the receiver when they are called:
var v Vertex
fmt.Println(v.Abs()) // OK
p := &v
fmt.Println(p.Abs()) // OK
Enter fullscreen mode Exit fullscreen mode
  • In this case, the method call p.Abs() is interpreted as (*p).Abs().
package main

import (
    "fmt"
    "math"
)

type Vertex struct {
    X, Y float64
}

func (v Vertex) Abs() float64 {
    return math.Sqrt(v.X*v.X + v.Y*v.Y)
}

func AbsFunc(v Vertex) float64 {
    return math.Sqrt(v.X*v.X + v.Y*v.Y)
}

func main() {
    v := Vertex{3, 4}
    fmt.Println(v.Abs())
    fmt.Println(AbsFunc(v),"\n")

    p := &Vertex{3, 4}
    fmt.Println(p.Abs())
    fmt.Println(AbsFunc(*p))
}
Enter fullscreen mode Exit fullscreen mode

Output:

5
5 

5
5
Enter fullscreen mode Exit fullscreen mode

Choosing a value or pointer receiver

package main

import (
    "fmt"
    "math"
)

type Vertex struct {
    X, Y float64
}

func (v *Vertex) Scale(f float64) {
    v.X = v.X * f
    v.Y = v.Y * f
}

func (v *Vertex) Abs() float64 {
    return math.Sqrt(v.X*v.X + v.Y*v.Y)
}

func main() {
    v := &Vertex{3, 4}
    fmt.Printf("Before scaling: %+v, Abs: %v\n", v, v.Abs())
    v.Scale(5)
    fmt.Printf("After scaling: %+v, Abs: %v\n", v, v.Abs())
}
Enter fullscreen mode Exit fullscreen mode

Output:

Before scaling: &{X:3 Y:4}, Abs: 5
After scaling: &{X:15 Y:20}, Abs: 25
Enter fullscreen mode Exit fullscreen mode

Interfaces

In Go, interfaces provide a way to define sets of methods that a type must implement. This enables polymorphism and allows different types to be treated interchangeably if they satisfy the interface contract. Here's an example of using interfaces in Go:

package main

import (
    "fmt"
    "math"
)

// Shape is an interface for geometric shapes
type Shape interface {
    Area() float64
    Perimeter() float64
}

// Rectangle represents a rectangle shape
type Rectangle struct {
    Width  float64
    Height float64
}

// Area calculates the area of the rectangle
func (r Rectangle) Area() float64 {
    return r.Width * r.Height
}

// Perimeter calculates the perimeter of the rectangle
func (r Rectangle) Perimeter() float64 {
    return 2 * (r.Width + r.Height)
}

// Circle represents a circle shape
type Circle struct {
    Radius float64
}

// Area calculates the area of the circle
func (c Circle) Area() float64 {
    return math.Pi * c.Radius * c.Radius
}

// Perimeter calculates the circumference of the circle
func (c Circle) Perimeter() float64 {
    return 2 * math.Pi * c.Radius
}

func main() {
    rect := Rectangle{Width: 5, Height: 3}
    circle := Circle{Radius: 2.5}

    shapes := []Shape{rect, circle}

    for _, shape := range shapes {
        fmt.Printf("Area: %f\n", shape.Area())
        fmt.Printf("Perimeter: %f\n", shape.Perimeter())
        fmt.Println("------------------")
    }
}
Enter fullscreen mode Exit fullscreen mode

Output:

Area: 15.000000
Perimeter: 16.000000
------------------
Area: 19.634954
Perimeter: 15.707963
------------------
Enter fullscreen mode Exit fullscreen mode

Stringers

In Go, the Stringer interface is a built-in interface that allows types to define their own string representation. The String() method of the Stringer interface returns a string representation of the type. Here's an example of using the Stringer interface in Go:

package main

import "fmt"

type Person struct {
    Name string
    other string
}

func (p Person) String() string {
    return fmt.Sprintf("%v %v", p.Name, p.other)
}

func main() {
    a := Person{"Jay!", "Shiya Ram"}
    z := Person{"Jay Shree!", "Radhe Krishna"}
    fmt.Println(a, z)
}

Enter fullscreen mode Exit fullscreen mode

Output:

Jay! Shiya Ram Jay Shree! Radhe Krishna
Enter fullscreen mode Exit fullscreen mode

Type Parameters

  • Go functions can be written to work on multiple types using type parameters. The type parameters of a function appear between brackets, before the function's arguments.
func Index[T comparable](s []T, x T) int
Enter fullscreen mode Exit fullscreen mode
  • This declaration means that s is a slice of any type T that fulfills the built-in constraint comparable. x is also a value of the same type.
  • comparable is a useful constraint that makes it possible to use the == and != operators on values of the type. In this example, we use it to compare a value to all slice elements until a match is found. This Index function works for any type that supports comparison.
package main

import "fmt"

// Index returns the index of x in s, or -1 if not found.
func Index[T comparable](s []T, x T) int {
    for i, v := range s {
        // v and x are type T, which has the comparable
        // constraint, so we can use == here.
        if v == x {
            return i
        }
    }
    return -1
}

func main() {
    // Index works on a slice of ints
    si := []int{10, 20, 15, -10}
    fmt.Println(Index(si, 15))

    // Index also works on a slice of strings
    ss := []string{"foo", "bar", "baz"}
    fmt.Println(Index(ss, "hello"))
}
Enter fullscreen mode Exit fullscreen mode

Output:

2
-1
Enter fullscreen mode Exit fullscreen mode

Goroutines

  • A goroutine is a lightweight thread managed by the Go runtime.
go f(x, y, z)
Enter fullscreen mode Exit fullscreen mode
  • starts a new goroutine running
f(x, y, z)
Enter fullscreen mode Exit fullscreen mode
  • The evaluation of f, x, y, and z happens in the current goroutine and the execution of f happens in the new goroutine.
  • Goroutines run in the same address space, so access to shared memory must be synchronized. The sync package provides useful primitives, although you won't need them much in Go as there are other primitives.
package main

import (
    "fmt"
    "time"
)

func say(s string) {
    for i := 0; i < 5; i++ {
        time.Sleep(100 * time.Millisecond)
        fmt.Println(s)
    }
}

func main() {
    go say("world")
    say("hello")
}
Enter fullscreen mode Exit fullscreen mode

Output:

hello
world
world
hello
world
hello
hello
world
world
hello
Enter fullscreen mode Exit fullscreen mode
  • In this code, we have two functions: say and main. The say function takes a string parameter s and prints it five times with a delay of 100 milliseconds between each print.
  • In the main function, we launch a new goroutine by calling go say("world"). This means that the say function with the argument "world" will be executed concurrently with the main goroutine.
  • Simultaneously, the main goroutine continues executing and calls say("hello"). As a result, "hello" will be printed five times in the main goroutine.
  • The output of this program will be somewhat unpredictable due to the concurrent nature of goroutines. It may vary on each execution, but you can expect to see interleaved "hello" and "world" messages.

Channels

channels provide a way for goroutines to communicate and synchronize their execution. Channels are used to pass data between goroutines and ensure safe concurrent access to shared resources. Here's an explanation of channels in Go:

1. Channel Creation:

  • To create a channel, you use the make function with the chan keyword followed by the type of data the channel will transmit. For example:
ch := make(chan int) // Creates an unbuffered channel of type int
Enter fullscreen mode Exit fullscreen mode

2. Channel Operations:

Channels support two fundamental operations: sending and receiving data.

  • Sending Data: To send data through a channel, you use the <- operator in the form channel <- value. For example:
ch <- 177 // Sends the value 177 into the channel 
Enter fullscreen mode Exit fullscreen mode
  • Receiving Data: To receive data from a channel, you use the <- operator on the left-hand side of an assignment. For example:
value := <-ch // Receives a value from the channel and assigns it to the variable "value"
Enter fullscreen mode Exit fullscreen mode

Consider the following example:

package main

import "fmt"

func sum(s []int, c chan int) {
    sum := 0
    for _, v := range s {
        sum += v
    }
    c <- sum // send sum to c
}

func main() {
    s := []int{7, 2, 8, -9, 4, 0}

    c := make(chan int)
    go sum(s[:len(s)/2], c)
    go sum(s[len(s)/2:], c)
    x, y := <-c, <-c // receive from c

    fmt.Println(x, y, x+y)
}
Enter fullscreen mode Exit fullscreen mode

Output:

-5 17 12
Enter fullscreen mode Exit fullscreen mode

The example code sums the numbers in a slice, distributing the work between two goroutines. Once both goroutines have completed their computation, it calculates the final result.

Buffered Channels

  • Channels can be buffered. Provide the buffer length as the second argument to make to initialize a buffered channel:
ch := make(chan int, 100)
Enter fullscreen mode Exit fullscreen mode
  • Sends to a buffered channel block only when the buffer is full. Receives block when the buffer is empty.
package main

import (
    "fmt"
)

func main() {
    // Create a buffered channel with a capacity of 3
    ch := make(chan int, 3)

    // Send values to the channel
    ch <- 1
    ch <- 2
    ch <- 3

    // Attempting to send another value to the channel would block since the buffer is full

    // Receive values from the channel
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)

    // Attempting to receive another value from the channel would block since the buffer is empty
}
Enter fullscreen mode Exit fullscreen mode

Output:

1
2
3
Enter fullscreen mode Exit fullscreen mode
  • In this example, we create a buffered channel ch with a capacity of 3 by specifying the capacity as the second argument to the make function.
  • We then send three values (1, 2, and 3) to the channel using the <- operator. Since the channel has a buffer capacity of 3, these sends will not block.
  • After sending the values, we receive and print them using the <- operator and fmt.Println() statements. Again, since the channel is buffered and contains three values, these receives will not block.
  • However, if we attempt to send or receive more values to/from the channel, it would block. For example, trying to send a value when the buffer is full or receive a value when the buffer is empty would cause the corresponding goroutine to block until space becomes available or a value is sent. -Buffered channels are useful when you want to decouple the send and receive operations in terms of timing, allowing the sender and receiver to operate independently up to the buffer capacity.

To continue reading and explore the other chapter, simply follow this link: Link To Other Chapters

Top comments (2)

Collapse
 
sahil_4555 profile image
Sahil Sojitra

Although Go does not implement polymorphism in the same way as Java or C++, it offers a mechanism to change behavior based on the data being operated on. In Go, this is achieved through the use of interfaces.

When a struct implements an interface in Go, it assumes the type of that interface. Consequently, any function that accepts the interface as a parameter can treat the struct as if it were an instance of that interface. While Go's approach differs from traditional polymorphism, it allows different types to be used interchangeably as long as they satisfy the interface requirements.

Go emphasizes defining behaviors rather than relying on class hierarchies, making the code more adaptable and independent of specific implementations. Although Go lacks function overloading seen in other languages, it still facilitates adjusting code behavior based on the data it operates on.

Collapse
 
utsavdesai26 profile image
Utsav Desai

Part 3 sounds promising! Exploring methods, interfaces, generics, and concurrency in Go has been enlightening so far. Eager to unlock more of Go's potential in this next installment.