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 thefunc
keyword and the method name. - In this example, the
Abs
method has a receiver of typeVertex
namedv
.
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))
}
Output:
5
13
- 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())
}
Output
-1.4142135623730951
1.4142135623730951
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())
}
Output:
30 40
30 40
50
30 40
3 4
5
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
- 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
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)
}
Output:
{6 8}
{60 80}
&{6 8}
&{60 80}
- 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!
- 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
- 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))
}
Output:
5
5
5
5
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())
}
Output:
Before scaling: &{X:3 Y:4}, Abs: 5
After scaling: &{X:15 Y:20}, Abs: 25
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("------------------")
}
}
Output:
Area: 15.000000
Perimeter: 16.000000
------------------
Area: 19.634954
Perimeter: 15.707963
------------------
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)
}
Output:
Jay! Shiya Ram Jay Shree! Radhe Krishna
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
- This declaration means that
s
is a slice of any typeT
that fulfills the built-in constraintcomparable
.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. ThisIndex
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"))
}
Output:
2
-1
Goroutines
- A goroutine is a lightweight thread managed by the Go runtime.
go f(x, y, z)
- starts a new goroutine running
f(x, y, z)
- 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")
}
Output:
hello
world
world
hello
world
hello
hello
world
world
hello
- In this code, we have two functions:
say
andmain
. Thesay
function takes a string parameters
and prints it five times with a delay of 100 milliseconds between each print. - In the
main
function, we launch a new goroutine by callinggo say("world")
. This means that thesay
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
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 formchannel <- value
. For example:
ch <- 177 // Sends the value 177 into the channel
- 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"
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)
}
Output:
-5 17 12
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)
- 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
}
Output:
1
2
3
- In this example, we create a buffered channel
ch
with a capacity of 3 by specifying the capacity as the second argument to themake
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 andfmt.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)
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.
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.