In this first post of the series “Golang in Twenty Minutes”, we will see the fundamentals of the Go language. I assume that the reader already knows at least one programming language.
Variable and Constant
A variable can be declared in two ways:
var name string
name = "Bob"
lastName := "Smith"Thus, we can declare a variable using the var keyword followed by a variable name and its type. The short form allows you to declare and assign a variable in a single step by setting its value; the variable then gets its type from the value assigned during initialization.
Like variables, constants exist, but their value cannot be changed once assigned.
const Pi float64 = 64
const G = 9.18In this case, the keyword is const, followed by the constant name, the type, and the value. Alternatively, using the short form, we can omit the type, which is inferred from the initialization.
Enum
Related to constants, there are enums. In Go, there is no dedicated enum keyword, but they are defined using const.
type Status int
const (
StatusPending Status = iota // 0
StatusActive // 1
StatussCompleted // 2
StatusCancelled // 3
)Using type, we define a new type that is an alias of int, then define a group of constants where we initialize the first one with iota. In this way, the following constants automatically receive incremented values.
Array
In Go, an array is a data structure with a fixed size because it is allocated on the stack; it is not possible to add or remove elements. We can declare it in different ways:
package main
import "fmt"
func main() {
// Declaration with size
var arr1 [5]int
fmt.Printf("arr1: %v (zero values)\n", arr1) // [0 0 0 0 0]
// Declaration with initialization
arr2 := [5]int{1, 2, 3, 4, 5}
fmt.Printf("arr2: %v\n", arr2) // [1 2 3 4 5]
// Partial initialization (rest are zero values)
arr3 := [5]int{1, 2}
fmt.Printf("arr3: %v\n", arr3) // [1 2 0 0 0]
// Let compiler count the size
arr4 := [...]int{10, 20, 30}
fmt.Printf("arr4: %v (length: %d)\n", arr4, len(arr4)) // [10 20 30] (length: 3)
// Access elements
arr2[0] = 100
fmt.Printf("Modified arr2: %v\n", arr2) // [100 2 3 4 5]
// Length and capacity are the same
fmt.Printf("len(arr2): %d, cap(arr2): %d\n", len(arr2), cap(arr2)) // 5, 5
}A note about the len and cap functions: len returns the number of elements currently present in the array, while cap returns the maximum number of elements the array can contain. For arrays, len and cap always return the same value, but this is not true for slices.
Slice
The slice is a struct with three fields: a pointer to the underlying array, length (number of elements), and capacity (size of the underlying array from the slice’s start). In short, it is a dynamic, indexed sequence with variable length, backed by an underlying array.
Let’s see how to create slices:
package main
import "fmt"
func main() {
// A slice is a struct with 3 fields:
// - pointer to underlying array
// - length (number of elements)
// - capacity (size of underlying array from the slice's start)
// Create slice with make
s1 := make([]int, 5) // length 5, capacity 5
fmt.Printf("s1: %v (len: %d, cap: %d)\n", s1, len(s1), cap(s1))
s2 := make([]int, 3, 10) // length 3, capacity 10
fmt.Printf("s2: %v (len: %d, cap: %d)\n", s2, len(s2), cap(s2))
// Create slice with literal
s3 := []int{1, 2, 3, 4, 5}
fmt.Printf("s3: %v (len: %d, cap: %d)\n", s3, len(s3), cap(s3))
// Empty slice
var s4 []int
fmt.Printf("s4: %v (len: %d, cap: %d, nil: %t)\n", s4, len(s4), cap(s4), s4 == nil)
}Other example:
package main
import "fmt"
func main() {
arr := [10]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
// Create slice from array
s := arr[2:7] // Elements from index 2 to 6 (7 is excluded)
fmt.Printf("Original array: %v\n", arr)
fmt.Printf("Slice s: %v (len: %d, cap: %d)\n", s, len(s), cap(s))
// s = [2 3 4 5 6], len=5, cap=8 (from index 2 to end of array)
// Modifying slice modifies the underlying array
s[0] = 999
fmt.Printf("After modifying s[0]:\n")
fmt.Printf("Array: %v\n", arr) // [0 1 999 3 4 5 6 7 8 9]
fmt.Printf("Slice: %v\n", s) // [999 3 4 5 6]
}String and Rune
A slice is a struct composed of three fields: a pointer to the underlying array, its length (the number of elements it contains), and its capacity (the total space available in the underlying array starting from the slice’s first element). In short, it is a dynamic, indexed sequence of variable length, backed by an underlying array.
Let’s see how to create slices:
package main
import (
"fmt"
"unicode/utf8"
)
func main() {
// String semplice ASCII
s1 := "hello"
fmt.Printf("String: %s\n", s1)
fmt.Printf("Length in bytes: %d\n", len(s1)) // 5 bytes
fmt.Printf("Rune count: %d\n", utf8.RuneCountInString(s1)) // 5 runes
// String con caratteri non-ASCII
s2 := "ciao"
fmt.Printf("\nString: %s\n", s2)
fmt.Printf("Length in bytes: %d\n", len(s2)) // 4 bytes
fmt.Printf("Rune count: %d\n", utf8.RuneCountInString(s2)) // 4 runes
// String con emoji e caratteri speciali
s3 := "Hello 世界 🌍"
fmt.Printf("\nString: %s\n", s3)
fmt.Printf("Length in bytes: %d\n", len(s3)) // 16 bytes!
fmt.Printf("Rune count: %d\n", utf8.RuneCountInString(s3)) // 9 runes
fmt.Println("\n--- Breakdown of s3 ---")
// 'H' = 1 byte
// 'e' = 1 byte
// 'l' = 1 byte
// 'l' = 1 byte
// 'o' = 1 byte
// ' ' = 1 byte
// '世' = 3 bytes
// '界' = 3 bytes
// ' ' = 1 byte
// '🌍' = 4 bytes
// Total: 16 bytes
}Still code:
package main
import "fmt"
func main() {
// Rune literal (single character in single quotes)
var r1 rune = 'A'
var r2 rune = '世'
var r3 rune = '🌍'
fmt.Printf("r1: %c (Unicode: U+%04X, decimal: %d)\n", r1, r1, r1)
fmt.Printf("r2: %c (Unicode: U+%04X, decimal: %d)\n", r2, r2, r2)
fmt.Printf("r3: %c (Unicode: U+%04X, decimal: %d)\n", r3, r3, r3)
// Output:
// r1: A (Unicode: U+0041, decimal: 65)
// r2: 世 (Unicode: U+4E16, decimal: 19990)
// r3: 🌍 (Unicode: U+1F30D, decimal: 127757)
}Now, let’s take a look at how to convert a string to runes and runes back to a string.
package main
import "fmt"
func main() {
s := "Ciao 世界"
// Convert string to slice of runes
runes := []rune(s)
fmt.Printf("String: %s\n", s)
fmt.Printf("Runes: %v\n", runes)
fmt.Printf("Number of runes: %d\n", len(runes)) // 7
// Now we can safely access by character index
fmt.Printf("First character: %c\n", runes[0]) // C
fmt.Printf("Last character: %c\n", runes[6]) // 界
// Modify runes
runes[0] = 'K'
runes[5] = '世'
// Convert back to string
newString := string(runes)
fmt.Printf("Modified string: %s\n", newString) // Kiao 世世
}Flows
The if statement is similar to those in other languages:
func main() {
// Simple if
x := 10
if x > 5 {
fmt.Println("x is greater than 5")
}
// If with else
if x > 15 {
fmt.Println("x is greater than 15")
} else {
fmt.Println("x is not greater than 15")
}
// If-else if-else chain
score := 85
if score >= 90 {
fmt.Println("Grade: A")
} else if score >= 80 {
fmt.Println("Grade: B")
} else if score >= 70 {
fmt.Println("Grade: C")
} else {
fmt.Println("Grade: F")
}
// If with initialization statement (very common checking the error)
if num := 42; num > 40 {
fmt.Printf("num (%d) is greater than 40\n", num)
}
}A unique feature of the if statement is the inclusion of an initialization step, often used with expressions that return an error.
Switch
Let’s take a look at the switch statement; notice that the break keyword is not required.
day := "Monday"
switch day {
case "Monday":
fmt.Println("Inizio settimana")
case "Friday":
fmt.Println("Quasi weekend!")
case "Saturday", "Sunday":
fmt.Println("Weekend!")
default:
fmt.Println("Giorno normale")
}yet:
num := 1
switch num {
case 1:
fmt.Println("One")
fallthrough // continue with next case
case 2:
fmt.Println("One or two")
default:
fmt.Println("Other")
}By using fallthrough after “case 1” is executed, “case 2” will also be executed. However, note that this is considered an anti-pattern, so it is rarely seen in idiomatic Go code.
The Only Loop You Need: For
In Go, there is only one looping construct: the for loop. Let’s take a look at the different ways we can use it:
import "fmt"
func main() {
nums := []int{10, 20, 30, 40, 50}
// Classic for with index
fmt.Println("--- Classic for loop ---")
for i := 0; i < len(nums); i++ {
fmt.Printf("Index: %d, Value: %d\n", i, nums[i])
}
// For-range (index and value)
fmt.Println("\n--- For-range (index + value) ---")
for i, v := range nums {
fmt.Printf("Index: %d, Value: %d\n", i, v)
}
// For-range (only value)
fmt.Println("\n--- For-range (only value) ---")
for _, v := range nums {
fmt.Printf("Value: %d\n", v)
}
// For-range (only index)
fmt.Println("\n--- For-range (only index) ---")
for i := range nums {
fmt.Printf("Index: %d\n", i)
}
// For-range with string (iterates by RUNE, not byte!)
fmt.Println("\n--- For-range on string ---")
s := "Go 世界"
for i, r := range s {
fmt.Printf("Byte pos: %d, Rune: %c (U+%04X)\n", i, r, r)
}
}We can use the classic C-style for loop, or iterate over a range to get both the index and value, only the value, or only the index.
FUNCTIONS
The functions in Go are defined using the keyword func. Parameters are passed by value, so how can we modify a parameter inside a function? We must use a pointer, but not in the same way as in C.
Take a look:
// Pass by VALUE - doesn't modify original
func incrementValue(x int) {
x = x + 10
fmt.Printf("Inside incrementValue: %d\n", x)
}
// Pass by POINTER - modifies original
func incrementWithPointer(x *int) {
*x = *x + 10
fmt.Printf("Inside incrementWithPointer: %d\n", *x)
}
The first function changes the value only locally within the function. The second changes the value pointed to by x. Note that the parameter contains the value of the pointer, so we can change what it points to, but we cannot change the pointer itself.
Maybe with an example it is clearer:
// Pass by POINTER but NOT modifies original
func noChnageWithPointer(x *int) {
y := 99
x = &y
fmt.Printf("Inside noChnageWithPointer: %d\n", *x) // 99
}
a := 5
noChnageWithPointer(&a)
// a = 5In this case, the function changes the local value of the pointer, so the original pointer remains untouched (as does the value it points to).
Here is a list of possible ways to declare a function:
// Simple function
func greet(name string) {
fmt.Printf("Hello, %s!\n", name)
}
// Function with return value
func add(a, b int) int {
return a + b
}
// Multiple parameters of same type (shorthand)
func multiply(x, y, z int) int {
return x * y * z
}
// Multiple return values (VERY common in Go!)
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, fmt.Errorf("division by zero")
}
return a / b, nil
}
// Named return values
func swap(x, y string) (first, second string) {
first = y
second = x
return // "naked return" - returns named values
}
// Variadic function (variable number of arguments)
func sum(nums ...int) int {
total := 0
for _, n := range nums {
total += n
}
return total
}
// Function as value
func applyOperation(a, b int, op func(int, int) int) int {
return op(a, b)
}
// Anonymous function (closure)
func makeCounter() func() int {
count := 0
return func() int {
count++
return count
}
}Note that slices and maps (see the next section) are always passed like pointers.
Struct
Structs are the main tool for defining user-defined types. They allow you to group different data types together, but they don’t contain logic. Unlike classes, you cannot define methods within the struct definition itself.
type User struct {
ID int
FirstName string
LastName string
Email string
IsActive bool
}To initialize a struct:
u := User{
ID: 1,
FirstName: "Mario",
LastName: "Rossi",
Email: "[email protected]",
IsActive: true,
}
// Access to the field with the point
fmt.Println(u.Email)Core Features:
- Heterogeneous Types: Unlike an array (where all elements must be of the same type), a struct allows you to mix int, string, bool, ecc. or even other structs.
- Zero Value: If you declare a struct without initializing it, each field will automatically take its “zero value” (e.g., 0 for numbers, “” for strings).
- Pass-by-value: By default, if you pass a struct to a function, Go creates a copy of it. To modify the original struct directly, you must use pointers (*User).
Map
A map is a built-in data type that associates a key with a value.
Take a look to the main patterns:
package main
import "fmt"
func main() {
// Declaration and initialization with make
m1 := make(map[string]int)
m1["apple"] = 5
m1["banana"] = 3
fmt.Printf("m1: %v\n", m1)
// Literal initialization
m2 := map[string]int{
"red": 1,
"green": 2,
"blue": 3,
}
fmt.Printf("m2: %v\n", m2)
// Access elements
fmt.Printf("m2[\"red\"] = %d\n", m2["red"])
// Check if key exists (IMPORTANT!)
value, exists := m2["yellow"]
if exists {
fmt.Printf("yellow exists: %d\n", value)
} else {
fmt.Printf("yellow doesn't exist (value: %d - zero value)\n", value)
}
// Accessing non-existent key returns zero value
fmt.Printf("m2[\"purple\"] = %d (zero value)\n", m2["purple"])
// Modify value
m2["red"] = 10
fmt.Printf("After modification: %v\n", m2)
// Delete key
delete(m2, "green")
fmt.Printf("After delete: %v\n", m2)
// Iterate over map (ORDER IS NOT GUARANTEED!)
fmt.Println("\n--- Iterating over map ---")
for key, value := range m2 {
fmt.Printf("%s: %d\n", key, value)
}
// Length of map
fmt.Printf("\nLength: %d\n", len(m2))
}The raccomandate way to create the map is “make”.
In Go, a map is implemented as a Hash Table. Here are the components that make it work “under the hood”:
- The Hash Function: When you insert a key (e.g., a string like “email”), Go uses a mathematical function to transform it into a number. This number is used to decide in which “bucket” to store the data.
- Buckets: The map’s memory is divided into small containers called buckets. Each bucket can hold up to 8 key-value pairs.
- The Bucket Array: The map is essentially a pointer to a structure called hmap, which contains an array of these buckets.
Core Characteristics:
- Unique Keys: You cannot have two identical keys. If you assign a value to an existing key, the old value will be overwritten.
- Key Types: The key must be of a comparable type (strings, numbers, booleans). You cannot use a slice as a key!
- Unordered: The order in which you insert data is not guaranteed when iterating over the map. If you print it twice, the order might change.
- Reference Type: Unlike structs, maps are pointers. If you pass a map to a function and modify it there, the changes will also affect the original map.
Others example:
package main
import "fmt"
func main() {
// Map of structs
type Person struct {
Name string
Age int
}
people := map[string]Person{
"nicola": {Name: "Nicola", Age: 30},
"mario": {Name: "Mario", Age: 25},
}
fmt.Printf("\nPeople: %v\n", people)
// Map with pointer values (to modify structs in place)
peoplePtr := map[string]*Person{
"nicola": {Name: "Nicola", Age: 30},
}
peoplePtr["nicola"].Age = 31 // Can modify directly
fmt.Printf("Modified age: %v\n", peoplePtr["nicola"])
// Nested maps
nested := map[string]map[string]int{
"fruits": {
"apple": 5,
"banana": 3,
},
"vegetables": {
"carrot": 10,
"potato": 7,
},
}
fmt.Printf("\nNested map: %v\n", nested)
fmt.Printf("fruits[apple]: %d\n", nested["fruits"]["apple"])
}That’s all for this first part; now it’s time to give Go a try!
Error handling
Error handling is not exception handling; errors are first-class values in Go.
Take a look to the common errors pattern in GO:
package main
import (
"errors"
"fmt"
)
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
func validateAge(age int) error {
if age < 0 || age > 150 {
return fmt.Errorf("invalid age: %d", age)
}
return nil
}
func main() {
// Basic error handling
result, err := divide(10, 2)
if err != nil {
fmt.Printf("Error: %v\n", err)
return
}
fmt.Printf("Result: %.2f\n", result)
// Error case
_, err = divide(10, 0)
if err != nil {
fmt.Printf("Error: %v\n", err)
}
// If with error evaluation (IDIOMATIC!)
if result, err := divide(20, 4); err != nil {
fmt.Printf("Error: %v\n", err)
} else {
fmt.Printf("Result: %.2f\n", result)
}
// Early return pattern
if err := validateAge(-5); err != nil {
fmt.Printf("Error: %v\n", err)
return
}
fmt.Println("Age is valid")
}
Exceptions in GO? A Debated question
I don’t want to start a battle on this topic, i present the panic/recovery mechanism the everyone can have a personal idea.
Go has the panic instruction that allows terminating a program with a panic message, but a program can also terminate with a panic due to some unmanaged runtime error, such as accessing a nil pointer.
It’s possible to intercept the panic and decide whether to terminate the program or continue with a different path; to do so, we have to use the recover instruction.
import "fmt"
func PanicExampleWithRecover() {
defer fmt.Println("Deferred statement 1")
defer fmt.Println("Deferred statement 2")
defer fmt.Println("Deferred statement 3")
defer func() {
if r := recover(); r != nil {
// handle the panic
fmt.Println("Recovered from panic:", r)
}
}()
var p *int
fmt.Println(*p) // panic: runtime error: nil pointer dereference
}
func main() {
PanicExampleWithRecover()
}
In the example we cause a panic but we recover it with the last defer. In this function we check if there is a panic (r not nil) and print the message (in a real program we can do any action to continue the execution). The defer functions are pushed onto the stack, and when there is a panic they are popped, so the first one to execute is the one that handles the panic, then “Deferred statement 3”, “Deferred statement 2” and finally “Deferred statement 1” (LIFO).
Is it like try/catch? Decide you :D.