Skip to content

Latest commit

 

History

History
1594 lines (1263 loc) · 41 KB

README.md

File metadata and controls

1594 lines (1263 loc) · 41 KB

Go

Go is good.

Main Page ↖

Knowledge requirements

  • Knowledge of any other programming language is a plus but not a requirement.

Contents

  1. Understanding Go
  2. Basics
  3. Functions
  4. Pointers
  5. Data Structures
  6. Generics
  7. Concurrency


🔶 Understanding Go

🔷 Essentials

🔻 Packages

In Go, code is organized into packages. A package is a collection of Go files in the same directory that are compiled together. Each Go file starts with a package statement that defines the package it belongs to.

There are two types of packages: the main package and other packages.

  • Main Package:

    This is the starting point of any Go application. A file with the package main statement defines the main package, and it must contain a main() function. The main() function is the entry point of the program where the execution starts.

  • Other Packages (Library Package):

    Other packages are used to organize code into reusable components. These packages group related functions, types, and variables together, making it easier to manage and maintain the code.

Important

In Go, only projects containing a main package with a main function can be executed directly as standalone programs. Other non-main packages, which do not include a main function, cannot be run on their own. These packages are designed as libraries or helpers that can be imported and used by other packages, including those with a main package.


🔻 Modules

A collection of related Go packages is called a module. A module has a go.mod file at the root of the module directory that defines the module's path and dependencies.

Note

The go.mod file is essential for both main packages and library packages. We create it every time we create a new project.

  • Initializing a module:

    The <module-path> is the root import path for all packages within the module and is usually the URL of the repository where the module is hosted.

    go mod init <module-path>

    So, running go mod init github.com/username/project will generate a go.mod file like this:

    module github.com/username/project
    
    go 1.22.4

🔷 Project Structure Examples

🔻 #1. An executable Go project with no extra packages.

  1. Create a directory called myapp for the project.

  2. Initialize a project (module):

    go mod init github.com/username/myapp
  3. Create a main.go file:

    package main
    
    import "fmt"
    
    func main() {
    	fmt.Println("Hello, World!")
    }
  4. We can create a different Go file (called calculate.go) under the same package:

    package main
    
    func add(a, b int) int {
    	return a + b
    }
    
    func subtract(a, b int) int {
    	return a - b
    }
  5. Using the new functions in the main file:

    You don't need to import the functions if they are defined in the same package. They are already part of the package.

    package main
    
    import "fmt"
    
    func main() {
    	fmt.Println(add(1, 2))      // 3
    	fmt.Println(subtract(5, 3)) // 2
    }

Overall directory structure:

myapp/
  ├── go.mod
  ├── main.go
  └── calculate.go

Note

A folder can only have files that belong to the same package.

Note

You cannot have multiple functions with the same name under a single package, even if they are in different files.


🔻 #2. An executable Go project with extra nested packages.

Overall directory structure:

myapp/
  ├── go.mod
  ├── main.go
  └── calculate/
          ├── basic.go
          └── advanced.go

The go.mod file remains in the root directory and handles the dependencies for the entire project. You don't need separate go.mod files for nested packages.

  1. main.go:

    You need to import the nested packages because they are not part of the importing package (main in this case).

    When you import nested packages, you need to use their full paths relative to the module path. github.com/username/myapp -> /calculate = github.com/username/myapp/calculate.

    package main
    
    import (
    	"fmt"
    
    	"github.com/username/myapp/calculate"
    )
    
    func main() {
    	fmt.Println(calculate.Add(1, 2))      // 3
    	fmt.Println(calculate.Subtract(5, 3)) // 2
    
    	fmt.Println(calculate.Square(5))      // 25
    	fmt.Println(calculate.SquareRoot(25)) // 5
    }
  2. calculate/

    • basic.go:

      package calculate
      
      func Add(a, b int) int {
      	return a + b
      }
      
      func Subtract(a, b int) int {
      	return a - b
      }
    • advanced.go:

      package calculate
      
      import "math"
      
      func Square(x int) int {
      	return x * x
      }
      
      func SquareRoot(x int) float64 {
      	return math.Sqrt(float64(x))
      }

Important

In Go, only identifiers (variables, functions, types, etc.) that start with an uppercase letter are exported and can be accessed from other packages.


🔻 #3. A separate non-executable Go library project.

Overall directory structure:

mylib/
  ├── go.mod
  ├── mylib.go
  └── generate/
          ├── name.go
          └── surname.go
  1. Create a directory called mylib for the library project.

  2. Initializing a library module:

    go mod init github.com/username/mylib
  3. mylib.go:

    package mylib
    
    import (
    	"fmt"
    
    	"github.com/username/mylib/generate"
    )
    
    func SayHi() {
    	fmt.Printf("Hi %s!\n", generate.Name())
    }
    
    func SayBye() {
    	fmt.Printf("Bye %s!\n", generate.Surname())
    }
    
    func GetFullName() string {
    	return fmt.Sprintf("%s %s", generate.Name(), generate.Surname())
    }
  4. generate/:

    • name.go:

      package generate
      
      func Name() string {
      	return "John"
      }
    • surname.go:

      package generate
      
      func Surname() string {
      	return "Doe"
      }


Todo:

#4. Using an external library/package in a Go project.

back to top ⬆



🔶 Basics

🔷 Basic Data Types

  • Boolean:

    Keyword Values
    bool true/false

    Zero value (default): false

  • Integer:

    Keyword Size Values
    int 32 bits / 64 bits int32 / int64
    int8 8 bits -128 to 127
    int16 16 bits -32768 to 32767
    int32 32 bits -2147483648 to 2147483647
    int64 64 bits -9223372036854775808 to 9223372036854775807
    uint 32 bits / 64 bits uint32 / uint64
    uint8 (alias for byte) 8 bits 0 to 255
    uint16 16 bits 0 to 65535
    uint32 32 bits 0 to 4294967295
    uint64 64 bits 0 to 18446744073709551615

    Zero value (default): 0

  • Float:

    Keyword Size Values
    float32 32 bits -3.4e+38 to 3.4e+38
    float64 64 bits -1.7e+308 to +1.7e+308

    Zero value (default): 0

  • String:

    Keyword Value
    string "anything surrounded by double quotes"

    Zero value (default): ""


🔷 Declaring Variables

  1. Using the var keyword:

    var varName type = value
    • You always have to specify either type or value (or both):
      var name string = "John"
      var surname = "Doe" // Go can infer the type of the variable from the initial value.
      var age int // zero value: 0
    • Declaring multiple variables of the same or different types in a single line:
      var name, age = "John", 30
      var a, b, c int = 1, 2, 3 // If the type keyword is used, it is only possible to  declare one type of variable per line.
    • Using grouped declaration syntax to declare variables together:
      var (
      	name    string = "John"
      	surname        = "Doe"
      	age     int
      )
  2. Using the := syntax:

    Then you use this syntax you must assign the value to the variable at the time of declaration.

    varName := value
    • This syntax is only available inside functions.
    • Declaring multiple variables in a single line:
      name, age := "John", 30

Note

Variables declared without an explicit initial value are given their zero value.


  1. Constants:

    Constants are fixed values that cannot be changed once they are set. They are read-only.

    const varName type = value
    • Constants can be declared without explicit types, but they must be declared with values:
      const daysInWeek int = 7
      const hoursInDay = 24
    • Declaring multiple constants in a single block:
      const (
      	daysInWeek = 7
      	hoursInDay = 24
      )
    • Computations on constants are done at compile time, not at runtime.

🔷 String Formatting

Placeholders Description Usage Output
%v Default placeholder for everything. ("%v", 123) 123
%s Plain string. ("%s", "hello") hello
%q String with double quotes. ("%q", "hello") "hello"
%d Integer in base 10. ("%d", 123) 123
%b Integer in base 2. ("%b", 123) 1111011
%o Integer in base 8. ("%o", 123) 173
%x Integer in base 16 with lowercase letters. ("%x", 123) 7b
%X Integer in base 16 with uppercase letters. ("%X", 123) 7B
%c Integer in Unicode code point. ("%c", 65) A
%f Floating-point number in decimal format. ("%f", 123.456) 123.456000
%e Floating-point number in scientific notation with a lowercase 'e' ("%e", 123.456) 1.234560e+02
%E Floating-point number in scientific notation with an uppercase 'E'. ("%E", 123.456) 1.234560E+02
%t Boolean value. ("%t", true) true
%T Prints the type of the value. ("%T", 123) int
%+v Prints struct fields with their names. ("%+v", Person{Name: "John", Age: 35}) {Name:John Age:35}
%% Prints a literal % sign. ("%%") %

Examples:

  • Printing a string and an integer:
    func main() {
    	name := "John"
    	age := 35
    	fmt.Printf("Name: %s, Age: %d", name, age) // Name: John, Age: 35
    }
  • Formatting a floating-point number:
    func main() {
    	pi := 3.14159
    	fmt.Printf("Pi: %.2f", pi) // Pi: 3.14
    }

back to top ⬆



🔶 Functions

  • Declaring a function:
    func add(a int, b int) int {
    	return a + b
    }
  • Calling a function:
    result := add(2, 3) // result = 5

Extra:

  • Functions can have multiple return values:

    func swap(a, b int) (int, int) {
    	return b, a
    }
    x, y := 1, 2
    x, y = swap(x, y) // x = 2, y = 1
  • Ignoring return values:

    Ignoring return values is useful when you don't need to use the return values of a function.

    x, _ = getCoords()
  • Named return values:

    When you have named return values, you can use the return keyword without specifying the values. It will automatically return the named return values. However, using the return keyword without specifying the values can hurt readability, so it is not recommended.

    func calculate(x int) (result int) {
    	result = x * x
    	return
    }
  • Variadic functions:

    Variadic functions are functions that can accept a variable number of arguments.

    func sum(numbers ...int) int {
    	total := 0
    	for _, number := range numbers {
    		total += number
    	}
    	return total
    }
  • First-class functions:

    First-class functions are functions that can be assigned to variables, passed as arguments to other functions, and returned as values from other functions.

    func main() {
    	price := 100.00
    
    	fmt.Printf("Regular user price: $%.2f\n", applyDiscount(price, regularDiscount)) //  Regular user price: $90.00
    	fmt.Printf("VIP user price: $%.2f\n", applyDiscount(price, vipDiscount))         //  VIP user price: $80.00
    }
    
    func vipDiscount(price float64) float64 {
    	return price * 0.8 // discount 20%
    }
    
    func regularDiscount(price float64) float64 {
    	return price * 0.9 // discount 10%
    }
    
    func applyDiscount(price float64, strategy func(float64) float64) float64 {
    	return strategy(price)
    }
  • Anonymous functions:

    Anonymous functions are functions that don't have a name.

    func main() {
    	func() {
    		fmt.Println("Hello, world!")
    	}() // <-- Immediately invoking the anonymous function
    }
    • Assigning the anonymous function to a variable:
      add := func(a, b int) {
      	fmt.Println(a + b)
      }
      add(1, 2) // 3

🔷 The defer Keyword

The defer keyword allows us to execute a function or line of code at the end of the current function's execution. This is useful for cleaning up resources, such as closing files or connections.

func main() {
	defer sayBye()

	fmt.Println("1")

	defer fmt.Println("2")

	fmt.Println("3")

	fmt.Println(myFunc())
}

func myFunc() string {
	defer fmt.Println("4")
	return "5"
}

func sayBye() {
	fmt.Println("bye")
}

Output:

1
3
4
5
2
bye

Multiple deferred statements are executed in last-in-first-out (LIFO) order. This means that the most recently deferred function is executed first.


🔷 Closures

Closures are functions that can access and manipulate variables from their outer scope, even after the outer function has finished executing.

func main() {
	count1 := adder()
	count2 := adder()

	fmt.Println(count1(1)) // 1
	fmt.Println(count1(2)) // 3
	fmt.Println(count1(3)) // 6

	fmt.Println(count2(2)) // 2
	fmt.Println(count2(4)) // 6
	fmt.Println(count2(6)) // 12

}

func adder() func(int) int {
	sum := 0

	return func(number int) int {
		sum += number
		return sum
	}
}

🔷 Pass-by Value/Reference

  • Pass-by-Value

    In Go, functions use pass-by-value for arguments. This means that when you pass a value to a function, Go creates a copy of that value and passes the copy to the function. The function works with this copy, so changes made to the parameter inside the function do not affect the original value.

    func main() {
    	num := 5
    	increment(num)
    	fmt.Println("Outside:", num) // Outside: 5
    }
    
    func increment(num int) {
    	num++
    	fmt.Println("Inside:", num) // Inside: 6
    }
  • Pass-by-Reference with Pointers

    If you need to modify the original value, you can use pointers. A pointer holds the memory address of a value. When you pass a pointer to a function, you're passing the address of the value, not a copy of it. This allows the function to modify the original value.

    func main() {
    	num := 5
    	increment(&num)
    	fmt.Println("Outside:", num) // Outside: 6
    }
    
    func increment(num *int) {
    	*num++
    	fmt.Println("Inside:", *num) // Inside: 6
    }

back to top ⬆



🔶 Pointers

Pointers in Go allow you to directly reference the memory address of a variable. This allows you to directly access and modify the value stored in that memory location.

  • &: To get the memory address of a variable.
  • *: To access or modify the value at the memory address.
func main() {
	// Declaring a variable:
	a := 10

	// Declaring a pointer and assigning it the address of 'a':
	ptr := &a

	fmt.Println(a)        // 10
	fmt.Printf("%T\n", a) // int (type of 'a')

	fmt.Println(ptr)        // 0xc0000a4010 (memory address of 'a')
	fmt.Printf("%T\n", ptr) // *int (type of 'ptr')
	fmt.Println(*ptr)       // 10 (value at the memory address)

	// Changing the value at the memory address:
	*ptr = 20

	fmt.Println(a) // 20
}

Passing pointers to functions allows you to modify the original value:

func main() {
	num := 10
	fmt.Println(num) // 10

	resetVal(num)
	fmt.Println(num) // 10

	resetPtr(&num)
	fmt.Println(num) // 0
}

func resetVal(val int) {
	val = 0
}

func resetPtr(val *int) {
	*val = 0
}

Warning

When a pointer does not point to a valid memory address, it is called a nil pointer. If you try to dereference a nil pointer, you will get a panic: runtime error: invalid memory address or nil pointer dereference error.

  • Bad: (this code will panic)
    func main() {
    	// Declaring a pointer (zero valued):
    	var ptr *int
    
    	fmt.Println(ptr) // <nil>
    
    	reset(ptr)
    }
    
    func reset(val *int) {
    	*val = 0 // panic: runtime error: invalid memory address or nil pointer dereference
    }
  • Good. Handling nil pointers: (this code will return an error instead of panicking)
    func main() {
    	var ptr *int
    	fmt.Println(ptr) // <nil>
    
    	fmt.Printf("reset(ptr): %v\n", reset(ptr)) // reset(ptr): invalid pointer
    }
    
    func reset(val *int) error {
    	if val == nil {
    		return errors.New("invalid pointer")
    	}
    
    	*val = 0
    
    	return nil
    }

Tip

Using pointers in Go can greatly improve performance by reducing memory usage and increasing speed, as they allow large data structures to be passed to functions without being copied. This efficiency comes from directly referencing memory addresses, which avoids the overhead of duplicating data.

However, pointers add complexity and can lead to memory-related bugs if not used carefully.

back to top ⬆



🔶 Data Structures

🔷 Structs

Structs in Go are a way to group related variables under a single name.

  • Defining a struct:
    type person struct {
    	name string
    	age  int
    }
  • Creating a struct instance:
    user := person{name: "John", age: 35}
    or zero valued:
    var user person
  • Accessing and modifying struct fields:
    fmt.Println(user.name) // John
    user.age = 30

Extra:

  • Embedded and nested structs:

    type address struct {
    	city    string
    	state   string
    	zipCode string
    }
    
    type contact struct {
    	phone string
    	email string
    }
    
    type person struct {
    	name    string
    	age     int
    	address         // Embedded struct
    	contact contact // Nested struct
    
    }
    
    func main() {
    	user := person{
    		name: "John",
    		age:  35,
    		address: address{
    			city:    "Los Angeles",
    			state:   "California",
    			zipCode: "00000",
    		},
    		contact: contact{
    			phone: "(000) 000-0000",
    			email: "[email protected]",
    		},
    	}
    
    	fmt.Println(user)               // {John 35 {Los Angeles California 00000} {(000) 000-0000 [email protected]}}
    	fmt.Println(user.city)          // Los Angeles
    	fmt.Println(user.contact.phone) // (000) 000-0000
    }

🔻 Structs Methods

  • Defining a method:
    func (p person) sayHello() {
    	fmt.Println("Hello, my name is " + p.name)
    }
  • Calling a method:
    user := person{name: "John", age: 35}
    user.sayHello() // Hello, my name is John

Extra:

  • Manipulating using methods:

    Here, we need to use pointers to manipulate the struct through the method because of the pass-by-value rule.

    func main() {
    	user := person{name: "John", age: 35}
    
    	user.sayHello() // Hello, my name is John
    
    	user.changeName("Sha'an")
    
    	user.sayHello() // Hello, my name is Sha'an
    }
    
    func (p *person) changeName(name string) {
    	p.name = name
    }

🔷 Arrays

In Go, array is a fixed-size sequence of elements of the same type.

  • Declaring an array:
    var arr [5]int // zero valued
    • Initializing at the time of declaration:
      arr := [5]int{1, 2, 3, 4, 5}
  • Accessing array elements:
    fmt.Println(arr[0]) // 1
  • Modifying array elements:
    arr[0] = 100
    fmt.Println(arr[0]) // 100

Extra:

  • Letting the compiler decide the size of the array:
    arr := [...]int{1, 5: 2, 3, 4, 5}
  • Initializing specific indexes of an array:

    When you use the index: value syntax, you specify the value for a particular index in the array. This allows you to skip some indices (with zero values) and directly assign values to others.

    arr := [...]int{10, 20, 5: 1, 30, 8: 2, 40}
    fmt.Println(arr) // [10 20 0 0 0 1 30 0 2 40]
  • Multi-dimensional arrays:
    arr2d := [3][2]int{
    	{1, 2},
    	{3, 4},
    	{5, 6},
    }
    fmt.Println(arr2d) // [[1 2] [3 4] [5 6]]
  • Slicing an array:

    Slicing allows you to create a slice from an array or an existing slice.

    arr := [5]int{0, 10, 20, 30, 40}
    fmt.Println(arr[1:3]) // [10 20]
    Syntax:
    slice1 := arr[2:4] // From index 2 to index 3
    slice2 := arr[:4]  // From the start to index 3
    slice3 := arr[4:]  // From index 4 to the end
    slice4 := arr[:]   // The entire array

🔷 Slices

In Go, a slice is a dynamically-sized, flexible view into the elements of an array. Unlike arrays, slices can grow and shrink in size.

  • Declaring a slice:
    var slice []int // zero length
    • Using the make function:

      non-zero length & zero valued

      slice := make([]int, 5)
    • Initializing at the time of declaration:
      slice := []int{1, 2, 3, 4, 5}

Extra:

  • Appending to a slice:
    slice := []int{1, 2, 3}
    slice = append(slice, 4, 5, 6)
    fmt.Println(slice) // [1 2 3 4 5 6]
  • Removing elements from a slice:
    • Removing the index 2 element:

      The ... syntax is known as the "variadic" operator. When you use it after a slice, it "unpacks" the slice so that its elements can be passed as individual arguments.

      slice = append(slice[:2], slice[3:]...)
    • Removing the last element:
      slice = slice[:len(slice)-1]
    • Removing all elements expect the first two:
      slice = slice[:2]

Note

When you pass a slice to a function, you are passing a reference to the underlying array, not a copy of the array's elements. Any modifications to the elements of the slice within the function will affect the original array.

func main() {
	slice := []int{1, 2, 3}

	fmt.Println("Before:", slice) // Before: [1 2 3]

	modifySlice(slice)

	fmt.Println("After:", slice) // After: [99 2 3]
}

func modifySlice(s []int) {
	s[0] = 99
}

While slices behave like pass-by-reference for the data they point to, the slice itself is passed by value. This means if you reassign the slice variable inside the function, it won't affect the original slice variable outside the function.

func main() {
	slice := []int{1, 2, 3}

	fmt.Println("Before:", slice) // Before: [1 2 3]

	reassignSlice(slice)

	fmt.Println("After:", slice) // After: [1 2 3]
}

func reassignSlice(s []int) {
	s = []int{100, 200, 300}
	s[0] = 999
}

Important

Slices have a length, which is the number of elements they contain, and a capacity, which is the size of the underlying array they reference.

If you append elements to a slice beyond its current capacity, Go will handle this automatically by allocating a (2x) larger array and copying the existing elements to it (to the new address). This can be an expensive operation in terms of performance.

var slice []int // slice is initially nil, with a length and capacity of 0.
fmt.Println(len(slice)) // 0
fmt.Println(cap(slice)) // 0

slice = append(slice, 1, 2, 3, 4)

fmt.Println(len(slice)) // len: 4
fmt.Println(cap(slice)) // cap: 4

slice = append(slice, 5, 6)

fmt.Println(len(slice)) // len: 6
fmt.Println(cap(slice)) // cap: 8

We can also predefine the capacity of a slice when we declare it:

slice := make([]int, 0, 10)

fmt.Println(slice)      // []
fmt.Println(len(slice)) // len: 0
fmt.Println(cap(slice)) // cap: 10

Caution

Never use append on anything other than itself.

func main() {
	a := make([]int, 5, 7) // 3rd argument is the capacity.
	fmt.Println("a:", a) // a: [0 0 0 0 0]

	b := append(a, 1)
	fmt.Println("b:", b) // b: [0 0 0 0 0 1]

	c := append(a, 2)

	fmt.Println("a:", a) // a: [0 0 0 0 0]
	fmt.Println("b:", b) // b: [0 0 0 0 0 2] <-- b got updated because of c
	fmt.Println("c:", c) // c: [0 0 0 0 0 2]
}

Here, when creating the b slice, the a slice has a capacity of 7 and a length of 5, which means it can add a new element without allocating a new array. So, b now references the same array as a. The same thing happens when creating c. It also references the same array as a. At this point, because both b and c share the same underlying array, appending 2 through c updates the 1 that was appended through b.

This unexpected behavior would not occur if there were not enough capacity for the new element. In that case, Go would allocate a new array and copy the existing elements to it, resulting in new addresses. But still, it is prone to go unexpected.


🔷 Maps

Maps in Go are used to store unordered collections of key-value pairs.

  • Declaring & initializing a map:

    You can't just declare a map with var m map[string]int and then assign values to it. If you try to do this, you'll get a panic: assignment to entry in nil map error. To make the map ready to use, you need to initialize it (either empty or with values) using the make function as shown below.

    m := make(map[string]int)
    • Initializing with values:
      m := map[string]int{
      	"one":   1,
      	"two":   2,
      	"three": 3,
      }
  • Accessing map elements:
    fmt.Println(m["one"]) // 1
  • Adding elements to a map:
    m["four"] = 4
    fmt.Println(m) // map[one:1 two:2 three:3 four:4]
  • Deleting elements from a map:
    delete(m, "two")
    fmt.Println(m) // map[one:1 three:3]
  • Modifying map elements:
    m["one"] = 100
    fmt.Println(m["one"]) // 100

Extra:

  • Checking if a key exists in a map:

    The optional second return value is a boolean indicating whether the key was found in the map.

    _, ok := m["one"]
    fmt.Println(ok) // true
  • Clearing all elements from a map:

    clear(m)
  • Nested maps:

    m2d := make(map[string]map[string]int)
    
    m2d["a"] = map[string]int{"first": 1}
    
    fmt.Println(m2d) // map[a:map[first:1]]
    
    fmt.Println(m2d["a"]["first"]) // 1

Caution

In Go, maps need to be initialized before you can use them.

func main() {
	m2d := make(map[string]map[string]int)

	m2d["a"]["b"] = 1 // <-- panic: assignment to entry in nil map

	m2d["a"] = make(map[string]int)

	m2d["a"]["b"] = 1 // <- ok

	fmt.Println(m2d["a"]["b"]) // 1
}

back to top ⬆



🔶 Generics

Generics in Go are a way to write reusable code that can work with different types. Instead of writing multiple versions of a function for different data types, you can write one generic function that works with any type.

Generics maintain type safety by allowing the compiler to check types at compile-time. This prevents many types of runtime errors.

Syntax:

func myFunc[T any](input T) T {
	return input
}

The type parameters are enclosed in square brackets [] and come after the function name.

  • Type Parameter:

    T is a type parameter that can represent any type. You can name it anything, but T is commonly used.

  • Constraint:

    The any constraint means that T can be any type (any is a shorthand for interface{}).


Example 1:

  • Without Generics:

    func main() {
    	fmt.Println(addInts(1, 2))       // 3
    	fmt.Println(addFloats(1.5, 2.3)) // 3.8
    
    }
    
    func addInts(a, b int) int {
    	return a + b
    }
    
    func addFloats(a, b float64) float64 {
    	return a + b
    }
  • With Generics:

    func main() {
    	fmt.Println(add(1, 2))     // 3
    	fmt.Println(add(1.5, 2.3)) // 3.8
    
    }
    
    func add[T int | float64](a T, b T) T {
    	return a + b
    }

Example 2:

func main() {
	fmt.Println(swap(5, 10))            // 10, 5
	fmt.Println(swap(2.5, 5.2))         // 5.2, 2.5
	fmt.Println(swap("Hello", "World")) // "World", "Hello"
}

func swap[T any](a, b T) (T, T) {
	return b, a
}

Example 3:

func main() {
	fmt.Println(swap(2.5, 5))      // 5, 2.5
	fmt.Println(swap("Hello", 99)) // 99, "Hello"
}

func swap[T any, U any](a T, b U) (U, T) {
	return b, a
}

Example 4:

Enforcing generic functions to work only with specific types.

type Number interface {
	int | float64
}

func multiply[T Number](a, b T) T {
	return a * b
}

func main() {
	fmt.Println(multiply(5, 6))             // 30
	fmt.Println(multiply(3.2, 4.1))         // 13.12
	fmt.Println(multiply("Hello", "World")) // string does not satisfy Number (string missing in int | float64) compiler(InvalidTypeArg)
}

back to top ⬆



🔶 Concurrency

🔷 Goroutines

Goroutines are a feature in Go that allows you to run functions concurrently.

In general, we can split the execution of a program into two types of routines:

  • Main Routine:

    The main routine is the initial goroutine that starts when a Go program begins execution. It's the entry point of the program, defined by the main function in the main package. When the main function exits, the program terminates, so any running goroutines will also be stopped.

  • Child Routines:

    A child routine is any goroutine that is spawned by the main routine or other goroutines. These are created using the go keyword followed by a function call. Child routines run concurrently with the main routine and each other.


Example:

func main() {
	go expensiveFunc("Hello")

	fmt.Println("Main")

	time.Sleep(1700 * time.Millisecond)
}

func expensiveFunc(text string) {
	for i := 0; i < 4; i++ {
		time.Sleep(500 * time.Millisecond)
		fmt.Println(text, i)
	}
}

Output:

Main
Hello 0
Hello 1
Hello 2

The time.Sleep inside the main function is used to give enough time for the goroutines to finish before the main function exits. Without this, the program would exit immediately after the main routine has done its job.

The output shows results for only 3 iterations, not 4 as specified in the for loop. This is because we have a time.Sleep of 1.7 seconds (1700 milliseconds), which is less than the minimum of 2 seconds (2000 milliseconds) needed for 4 iterations (4 * 500 ms = 2000 ms) in the expensiveFunc function.


🔷 Channels

Channels in Go are a way to communicate between goroutines. They are used to send and receive values between goroutines.

  • Declaring a channel:

    The type of a channel specifies what kind of data it can carry.

    ch := make(chan int)
  • Sending values to a channel:
    ch <- 10
  • Receiving values from a channel:
    myVar := <-ch
  • Closing a channel:

    Closing a channel is a way to signal to the receiving goroutine that it should stop waiting for values to be sent to it. It's important to close a channel when you're done sending values to avoid a deadlock.

    close(ch)

Note

Channel synchronization ensures that communication between goroutines is properly coordinated. It guarantees that data sent between goroutines is not lost and that goroutines wait for each other when necessary, maintaining the correct order and timing of operations.

  • Send Operation: ch <- value

    When a goroutine sends a value to a channel, it blocks/waits until another goroutine receives that value from the channel.

  • Receive Operation: value := <-ch

    When a goroutine receives a value from a channel, it blocks until there is a value available to receive.


Example:

func main() {
	ch := make(chan string)

	go expensiveFunc("Hello", ch)

	fmt.Println("Main")

	for i := 0; i < 4; i++ {
		fmt.Println(<-ch)
	}

  fmt.Println("End")
}

func expensiveFunc(text string, ch chan string) {
	for i := 0; i < 4; i++ {
		time.Sleep(500 * time.Millisecond)
		ch <- text + " " + fmt.Sprint(i)
	}
}

Output:

Main
Hello 0
Hello 1
Hello 2
Hello 3
End

Here, we don't need any additional mechanism in the main function to wait for the goroutines to finish. The <-ch operation in the main function blocks until there is a value to receive from the channel. This blocking behavior synchronizes the main function with the expensiveFunc goroutine. Each iteration of the loop in the main function waits for a corresponding send operation from expensiveFunc. Btw, we are not forced to use a loop here. We can use fmt.Println(<-ch) directly 4 times, one after the other, it does the same thing.

In this specific example, we don't strictly need to close the channel because the main function will only receive a fixed number of messages (4 in this case) and then it stops.

Here is the modified version of it that needs to be closed explicitly:

func main() {
	ch := make(chan string)

	go expensiveFunc("Hello", ch)

	fmt.Println("Main")

	for msg := range ch {
		fmt.Println(msg)
	}

	fmt.Println("Done.")
}

func expensiveFunc(text string, ch chan string) {
	for i := 0; i < 4; i++ {
		time.Sleep(500 * time.Millisecond)
		ch <- text + " " + fmt.Sprint(i)
	}

	close(ch)
}

The for msg := range ch { ... } syntax essentially performs a msg := <-ch operation under the hood, which is where the blocking behavior occurs.


🔻 Channel Status

The receiver of a channel can check the status of the channel using the second return value of the receive operation.

val, ok := <-ch

The second return value (ok here) is a boolean that indicates whether the channel is closed or not.

  • true: The channel is closed and no more values can be sent to it.
  • false: The channel is open and values can be sent to it.
func main() {
	ch := make(chan int)

	go func() {
		ch <- 1
		ch <- 2
		close(ch)
	}()

	for {
		val, ok := <-ch
		if !ok {
			fmt.Println("Channel is closed.")
			break
		}
		fmt.Println("Received:", val)
	}
}

When you range over a channel, the loop will automatically break when the channel is closed. But if you're using a manual receive loop, checking ok helps you know when to stop receiving.


🔻 The select Statement

The select statement in Go is a control structure that allows you to work with multiple channels simultaneously. It's similar to a switch statement but is specifically designed for channel operations.

  • The select statement listens to several channels.
  • It runs the first case that's ready to proceed.
  • If multiple cases are ready, Go randomly picks one to execute.
  • If there's no default case, it will block and wait until a case becomes ready.
  • If a default case is present and no other cases are ready, it executes the default case immediately without blocking.
func main() {
  // Preparation:
	ch1 := make(chan int)
	ch2 := make(chan int)
	ch3 := make(chan int)

	go func() {
		time.Sleep(time.Second)
		ch1 <- 1
	}()

	go func() {
		time.Sleep(time.Second * 3)
		ch2 <- 2
	}()

	go func() {
		time.Sleep(time.Second * 2)
		ch3 <- 3
	}()

	time.Sleep(time.Second * 5)

  // Usage:
	select {
	case val1 := <-ch1:
		fmt.Println(val1)
	case val2 := <-ch2:
		fmt.Println(val2)
	case val3 := <-ch3:
		fmt.Println(val3)
	default:
		fmt.Println("No channels are ready.")
	}

	fmt.Println("Done.")
}

Note

The select statement is not a loop. It executes only one case even if multiple are ready, or it is a default case. And then breaks the select statement.


🔻 Read-Only and Write-Only Channels

In Go, channels can be restricted to be either read-only or write-only. This is useful for defining clear communication patterns between goroutines and helps in maintaining code safety and clarity.

  • Read-only channels:

    A read-only channel can only be used to receive values. You cannot send values into a read-only channel.

  • Write-only channels:

    A write-only channel can only be used to send values. You cannot receive values from a write-only channel.

func sendData(ch chan<- int) { // ch is write-only
	ch <- 42
	close(ch)
}

func receiveData(ch <-chan int) { // ch is read-only
	fmt.Println(<-ch)
}

func main() {
	ch := make(chan int)

	go sendData(ch)
	receiveData(ch)
}

back to top ⬆



````