01 - Basics

1) Variables

Declaration and Initialization

Go is statically typed: the type of every variable is known at compile time. You can declare variables with an explicit type, rely on type inference, or use the short declaration inside functions.

vars.go
var x int = 10      // explicit type and initialization
var y = 20          // type inferred as int
z := 30             // short declaration (only inside functions)

Zero Values

Every variable in Go always has a value. If not explicitly initialized, it gets the zero value for its type:

  • bool => false
  • string => "" (empty string)
  • Numeric types (int, float64, complex128, etc.) => 0 (with correct type)
  • rune => 0 (represents U+0000)
  • byte => 0 (alias for uint8)
  • Reference types (slice, map, chan, pointer, interface, func) => nil
zero-values.go
var a int
var s string
var ok bool
fmt.Println(a, s, ok) // 0 "" false
TypeZero valueExample decl
boolfalsevar b bool
string""var s string
int / float640var n int / var f float64
complex128(0+0i)var c complex128
[]T / map[K]V / chan Tnilvar xs []int
*T (pointer)nilvar p *int
interface / funcnilvar r io.Reader / var fn func()

Zero values at a glance

Constants

Constants represent values known at compile time. They are immutable and don’t occupy memory like variables - the compiler substitutes them directly.

consts.go
const Pi = 3.14159
  • Constants are evaluated at compile time.
  • The compiler performs constant folding (e.g. 2*Pi is computed at compile time).
Untyped vs typed constants
untyped-consts.go

package main

import "fmt"

const u = 5   // untyped int
const f = 5.6 // untyped float

func main() {
	var a int = u     // ok: u => int
	var b float64 = u // ok: u => float64
	fmt.Println(a, b) // 5 5

	var x float64 = f // ok: f => float64
	fmt.Println(x)    // 5.6

	// var i int = f       // error: 5.6 cannot become int
	// var i int = int(f)  // still error: constants must fit exactly

	v := f             // bind constant to variable first
	var i int = int(v) // explicit conversion (runtime) => 5
	fmt.Println(i)
}
Note

Key idea

Untyped constants adapt to the needed type if the value fits. 5 can be int or float64. But lossy conversions like 5.6 => int are never implicit - you must convert explicitly.
Note

Compile-time vs Runtime conversions

Go treats constants and variables differently. Constants: conversions are checked at compile time. They must be exact. Example: const f = 5.6; var i int = int(f) => compile error, because 5.6 is not an exact int. Variables: conversions happen at runtime. Example: v := f; var i int = int(v) => works, because now v is a float64 variable and Go allows a lossy runtime conversion (fraction is truncated).

Built-in Types & Keywords

Go has a fixed set of basic types and reserved keywords. Knowing them helps avoid confusion and name clashes.

CategoryExamples
Booleanbool
Numeric (integers)int, int8, int16, int32, int64, uint, uint8 (alias byte), uint16, uint32, uint64, uintptr
Numeric (floating)float32, float64
Numeric (complex)complex64, complex128
Textstring, rune (alias for int32)
Other built-in typeserror
Warning

What about `uintptr`?

uintptr is a low-level integer used for pointer arithmetic and unsafe/system code. Don’t use it as a general number type.
Note

Reserved words (cannot be used as identifiers)

break, default, func, interface, select, case, defer, go, map, struct, chan, else, goto, package, switch, const, fallthrough, if, range, type, continue, for, import, return, var.
Note

Predeclared identifiers (can be shadowed - but don't)

Built-in names like int, string, len, make, append, copy, close, new, panic, recover, etc., are available in every file. They are not keywords, so you can shadow them with your own variables - but doing so is confusing and discouraged.
shadowing.go
package main

import "fmt"

func main() {
	len := 42
	fmt.Println(len)    // prints 42

	// fmt.Println(len([]int{1,2,3}))
	// ^ This would now FAIL because 'len' refers to your variable, not the built-in.
}
Warning

Avoid shadowing built-ins

Shadowing compiles, but it hides the built-in meaning and confuses readers (and future you). Always choose a different name.

Best Practices

  • Prefer short := for local variables.
  • Use const for values that never change (leverage untyped constants for ergonomic arithmetic).
  • Keep package-level variables rare; prefer passing values explicitly.
  • Do not shadow predeclared identifiers (len, string, int, append, ...).

2) Functions

Basic Function

add.go
func add(a int, b int) int {
	return a + b
}

Multiple Return Values

Go functions can return more than one value.

divmod.go
func divmod(a, b int) (int, int) {
	return a / b, a % b
}

Named Return Values

named-returns.go
func split(sum int) (x, y int) {
	x = sum * 4 / 9
	y = sum - x
	return // returns x, y
}

Passing by Value vs by Pointer

All arguments in Go are passed by value. To modify, you must pass a pointer.

pass.go
func increment(x int) {
	x++
}

func incrementPtr(x *int) {
	*x++
}

a := 5
increment(a)    // copy, no effect
incrementPtr(&a) // modifies original

Best Practices

  • Keep functions small and focused.
  • Use multiple return values for errors (value, err).
  • Use pointers when mutation or avoiding copies of large structs is needed.

3) Control Flow

If / Else

if-else.go
if x > 10 {
	fmt.Println("big")
} else {
	fmt.Println("small")
}

• Can include short initialization:

if-init.go
if n := len(s); n > 0 {
	fmt.Println("string not empty")
}

Switch

  • More concise than many ifs.
  • No implicit fallthrough - once a case matches, only that case runs and the switch stops, unlike C where execution continues into the next case automatically.
switch.go
switch day {
	case "Mon", "Tue":
	fmt.Println("Weekday")
	case "Sat", "Sun":
	fmt.Println("Weekend")
	default:
	fmt.Println("Unknown")
}

For Loops

Go has only one loop keyword: for.

for-classic.go
for i := 0; i < 3; i++ {
	fmt.Println(i)
}

While-like:

for-while.go
for x < 10 {
	x++
}

Infinite:

for-infinite.go
for {
	fmt.Println("looping forever")
}

Range

Iterates over slices, arrays, maps, strings, channels.

range-collection.go
for i, v := range []string{"a", "b", "c"} {
	fmt.Println(i, v)
}

Over strings, it yields runes (Unicode code points):

range-string.go
for i, r := range "Hello, 世界" {
	fmt.Printf("%d: %c\n", i, r)
}

Best Practices

  • Prefer range for collections.
  • Use switch instead of long if/else chains.
  • Avoid infinite loops unless you have a clear break condition.

4) Pointers

Basics

  • A pointer stores the address of a value.
  • &x gives the address of x, *p dereferences the pointer.
ptr-basics.go
x := 42
p := &x
fmt.Println(*p) // 42

Mutability

Go copies values by default. To mutate, you must pass a pointer.

ptr-mutate.go
func setZero(x *int) {
	*x = 0
}

a := 5
setZero(&a)
fmt.Println(a) // 0

Pointer Arithmetic

  • Go does not allow pointer arithmetic like C.
  • Only possible via the unsafe package (not recommended for production).

Stack vs Heap

  • Small, short-lived variables often stay on the stack.
  • If a pointer to a local variable escapes, Go allocates it on the heap.
  • Detailed analysis in the Memory lecture.

Best Practices

  • Use pointers to avoid copying large structs.
  • Use pointers when mutation is intended.
  • Avoid pointers when immutability is desired.

5) Strings, Bytes, and Runes in Go

Strings

  • A string is an immutable sequence of bytes.
  • Internally: pointer to the data + length.
  • Because strings are immutable, you cannot change them in place.
string-len.go
s := "Hello, 世界"
fmt.Println(len(s)) // 13 (7+3+3) => number of bytes, not characters
Note
NB: len counts bytes, not characters.

Bytes

  • A byte is just an alias for uint8 (a number 0-255).
  • If you convert a string to []byte, you see the raw UTF-8 encoding.
bytes-basic.go
b := []byte("ABC")
fmt.Println(b) // [65 66 67]

- "A" = 65, "B" = 66, "C" = 67 in ASCII/UTF-8.

Example with Unicode:

bytes-unicode.go
s := "世界"
fmt.Println([]byte(s)) // [228 184 150 231 149 140]

That’s the UTF-8 encoding of the characters.

Runes

  • A rune is an alias for int32.
  • It represents a Unicode code point (a single logical character).
runes.go
r := []rune("世界")
fmt.Println(r) // [19990 30028]
fmt.Println(string(r)) // 世界

- [19990 30028] are the Unicode numbers for "世" and "界".

Why the Difference Matters

why-bytes-vs-runes.go
s := "世界"

// As bytes
fmt.Println(len(s))       // 6 => 6 bytes
fmt.Println([]byte(s))    // [228 184 150 231 149 140]

// As runes
fmt.Println(len([]rune(s))) // 2 => 2 characters
  • 世界 looks like 2 characters, but UTF-8 encodes each with 3 bytes.
  • Strings in Go care about bytes, not characters.

Iterating Over Strings in Go

Strings in Go are UTF-8 encoded byte sequences. That means a character (rune) may take 1-4 bytes. How you iterate depends on whether you want raw bytes or Unicode characters (runes).

By Bytes

Indexing a string directly (s[i]) gives you one byte (0-255). This is fine for ASCII but wrong for Unicode characters.

iterate-bytes.go
s := "世界"
for i := 0; i < len(s); i++ {
	fmt.Printf("Byte %d = %x\n", i, s[i])
}

Output: Byte 0 = e4 Byte 1 = b8 Byte 2 = 96 Byte 3 = e7 Byte 4 = 95 Byte 5 = 8c

Note
NB: "世" is split into 3 bytes: e4 b8 96. So byte iteration = raw encoding, not characters.
By Runes (Idiomatic with `range`)

The range keyword automatically decodes UTF-8 into runes (Unicode code points).

iterate-range.go
s := "世界"
for i, r := range s {
	fmt.Printf("Index %d: Rune %c (U+%04X)\n", i, r, r)
}

Output: Index 0: Rune 世 (U+4E16) Index 3: Rune 界 (U+754C) Correct Unicode iteration. Note how "界" starts at index 3 because "世" used 3 bytes.

By Runes (Manual Decoding with `utf8.DecodeRuneInString`)

For fine control (skipping, peeking, error handling), you can manually decode.

iterate-manual.go
import "unicode/utf8"

s := "世界"
for i := 0; i < len(s); {
	r, size := utf8.DecodeRuneInString(s[i:])
	fmt.Printf("Byte %d: Rune %c (U+%04X), size=%d\n", i, r, r, size)
	i += size
}

Output: Byte 0: Rune 世 (U+4E16), size=3 Byte 3: Rune 界 (U+754C), size=3 Same result as range, but with explicit size handling.

By Converting to []rune

Convert the string into a rune slice for easy random access by character index.

iterate-runes-slice.go
s := "世界"
runes := []rune(s)

for i, r := range runes {
	fmt.Printf("Rune index %d: %c (U+%04X)\n", i, r, r)
}

Output: Rune index 0: 世 (U+4E16) Rune index 1: 界 (U+754C) Indexes now count characters, not bytes. Requires allocation (new slice in memory).

Strings Are Immutable

strings-immutable-bad.go
s := "hello"
s[0] = 'H' // compile error

Instead:

strings-immutable-good.go
s2 := "H" + s[1:]
fmt.Println(s2) // Hello

If you need mutability => convert to []byte or []rune.

strings-mutate.go
s := "世界"
b := []byte(s)
b[0] = 0x41 // 'A'
fmt.Println(string(b)) // Aˆ–界 (broken! messed up UTF-8)

r := []rune(s)
r[0] = 'A'
fmt.Println(string(r)) // A界 (works correctly)

Best Practices

  • Use string for normal text.
  • Use []byte for binary data (files, network).
  • Use []rune when you care about characters.
Note
NB: len(string) = bytes
Note
NB: len([]rune(string)) = characters

6) Strings Advanced

Internally, a Go string is just:

  • A pointer to the underlying byte array
  • A length (number of bytes, not characters)

In the runtime, this is represented by reflect.StringHeader:

string-header.go
type StringHeader struct {
	Data uintptr // pointer to the actual bytes
	Len  int     // length in bytes
}

Example: Inspecting a String

inspect-string.go
package main

import (
	"fmt"
	"reflect"
	"unsafe"
)

func main() {
	s := "Hello, 世界"

	hdr := (*reflect.StringHeader)(unsafe.Pointer(&s))
	fmt.Println("Data pointer:", hdr.Data)
	fmt.Println("Length:", hdr.Len)
	fmt.Println("Bytes:", []byte(s))
}
  • Data pointer: 4966115
  • Length: 13
  • Bytes: [72 101 108 108 111 44 32 228 184 150 231 149 140]
  • The Data pointer points to the immutable byte array holding those 13 bytes.

Example: reversing a string

reverse-string.go
package main

import (
	"fmt"
)

func rev(s string) string {
	newStr := []rune(s)
	n := len(newStr)
	for i := 0; i < n/2; i++ {
		newStr[i], newStr[n-1-i] = newStr[n-1-i], newStr[i]
	}
	return string(newStr) // convert the rune slice back into a string
}

func main() {
	str := "abcde世界"
	str2 := rev(str)
	fmt.Println(str2) // prints: 界世edcba
}

7) Structs

Basics

A struct groups related fields.

struct-basic.go
type Person struct {
	Name string
	Age  int
}

Instantiation

struct-instantiation.go
p := Person{Name: "Alice", Age: 30}
fmt.Println(p.Name)

Methods

  • Functions with a receiver.
  • Under the hood, methods are just functions where the receiver is the first parameter.
method-pointer.go
func (p *Person) Birthday() {
	p.Age++
}

Equivalent to:

method-func.go
func Birthday(p *Person) {
	p.Age++
}

Value vs Pointer Receiver

  • Value receiver => works on a copy.
  • Pointer receiver => works on the original.

Composition (No Inheritance)

Go does not have inheritance like traditional OOP. Instead, it uses composition: embedding one struct inside another. Methods of the embedded struct get promoted.

composition.go
package main

import "fmt"

type Address struct {
	City, Country string
}

type Person struct {
	Name string
	Address // embedded (composition)
}

func main() {
	p := Person{
		Name:    "Alice",
		Address: Address{City: "Paris", Country: "France"},
	}
	fmt.Println(p.Name, "lives in", p.City, p.Country) // City and Country promoted
}

8) Interfaces

Basics

An interface defines a set of method signatures. A type satisfies an interface if it has those methods (implicit implementation).

interface-basic.go
package main

import "fmt"

type Describer interface {
	Describe() string
}

type Person struct {
	Name string
}

func (p Person) Describe() string {
	return "Person: " + p.Name
}

func Print(d Describer) {
	fmt.Println(d.Describe())
}

func main() {
	p := Person{Name: "Alice"}
	Print(p) // works because Person has Describe()
}

Key Points

  • Implementation is implicit - no 'implements' keyword.
  • Interfaces are satisfied automatically if methods match.
  • Common for decoupling code and mocking in tests.

Interface Composition

Interfaces can be composed by embedding other interfaces. This is how Go builds larger behaviors from smaller ones (just like struct composition).

interface-composition.go
package main

import (
	"fmt"
)

// Define two small interfaces
type MyReader interface {
	Read() string
}

type MyWriter interface {
	Write(s string)
}

// Compose them into a bigger interface
type MyReaderWriter interface {
	MyReader
	MyWriter
}

// A type that implements both Read and Write
type MemoryBuffer struct {
	data string
}

func (m *MemoryBuffer) Read() string {
	return m.data
}

func (m *MemoryBuffer) Write(s string) {
	m.data = s
}

func main() {
	var rw MyReaderWriter = &MemoryBuffer{}

	// Use it via the combined interface
	rw.Write("Hello Interfaces")
	fmt.Println("Read back:", rw.Read())
}
Note
Example: io.ReadWriter is just io.Reader + io.Writer. If a type implements both, it implements io.ReadWriter automatically.

9) Immutability

Value Types

  • Examples: int, float, bool, struct
  • These are copied on assignment or function call.
  • Each copy is independent => changing one does not affect the other.
immut-value.go
package main

import "fmt"

func mutateValue(x int) {
	x = 100 // local copy only
}

func main() {
	a := 42
	mutateValue(a)
	fmt.Println(a) // 42 => unchanged
}

With Structs

immut-struct.go
type Point struct {
	X, Y int
}

func move(p Point) {
	p.X = 10
}

func main() {
	p1 := Point{1, 2}
	move(p1)
	fmt.Println(p1) // {1 2}, original not changed
}

Best practice: Passing large structs by pointer is more efficient if you need to mutate them.

Pointers and Mutability

If you pass a pointer, the function can modify the original value.

immut-pointer.go
func mutatePointer(x *int) {
	*x = 100
}

func main() {
	a := 42
	mutatePointer(&a)
	fmt.Println(a) // 100 => changed!
}

Best practice: Use pointers only when mutation is intended and clear. Otherwise, prefer values for safety and clarity.

Strings (Special Case)

  • Strings in Go are immutable sequences of bytes.
  • You cannot mutate them directly.
immut-strings-bad.go
func main() {
	s := "hello"
	// s[0] = 'H' // compile error: cannot assign to s[0]

	// Instead: create a new string
	s2 := "H" + s[1:]
	fmt.Println(s2) // Hello
}

Best practice: Treat strings as immutable. If you need mutability, convert to []byte or []rune

Key Takeaways

CategoryExamplesBehaviorMutable?
Value typesint, float, bool, structCopiedImmutable by copy
StringstringReference-likeImmutable
Pointer*TRefers to originalMutable
  • Value types are copied, so functions work on their own copy.
  • Pointers let functions mutate the original value.
  • Strings are immutable - always create new strings for modifications.

10) Bitwise Operators

What & why

Bitwise operations work directly on the individual bits of an integer. They’re often used for things like flags, permissions, fast math with powers of two and so on.

Operators at a glance

OperatorNameEffectExample
&AND1 only if both bits are 10b1100 & 0b1010 = 0b1000
|OR1 if any bit is 10b1100 | 0b1010 = 0b1110
^ (binary)XOR1 if bits differ0b1100 ^ 0b1010 = 0b0110
^x (unary)NOT (complement)inverts every bit^0b1010 = ...11110101
&^AND-NOT (bit clear)clear bits present in right operand0b1100 &^ 0b1010 = 0b0100
<<Left shiftmove bits left, fill with zeros1 << 3 = 0b1000
>>Right shiftmove bits right; unsigned fills with zeros8 >> 2 = 0b0010

Reading and writing bits

Test a bit
bit-test.go
package main

import "fmt"

func IsSet(x uint64, bit uint) bool {
	return x&(1<<bit) != 0
}

func main() {
	var x uint64 = 0b10100
	fmt.Println(IsSet(x, 2)) // true  (bit 2 → 4's place)
	fmt.Println(IsSet(x, 0)) // false
}
Set a bit to 1
bit-set.go
x := uint64(0)
x |= 1 << 4 // set 5th bit => 0b10000
Clear a bit to 0
bit-clear.go
x := uint64(0b11111)
x &^= 1 << 2 // clear 3rd bit => 0b11011
Toggle a bit
bit-toggle.go
x := uint64(0b0101)
x ^= 1 << 0  // => 0b0100
x ^= 1 << 2  // => 0b0000

Bitset in Go

Write a data structure DynamicSet that represents a set of integers in the range [0, N]. The set must support the following operations efficiently:

  • Add(x) - insert an element
  • Remove(x) - delete an element
  • Toggle(x) - flip presence of an element
  • Contains(x) - check if an element is present
  • ClearAll() - remove all elements
  • SetAll() - include all elements in [0, N]
  • Count() - return the number of elements
  • ForEach(fn) - iterate over elements
  • Union(a, b) - produce a new set with all elements from both
  • Intersection(a, b) - produce a new set with elements common to both
bit-set.go
package main

import (
	"fmt"
	"math/bits"
	"strings"
)

type DynamicSet struct {
	N       uint
	buckets []uint8
}

const bitsPerBucket uint = 8

func bucketsFor(n uint) int {
	totalBits := n + 1
	return int((totalBits + bitsPerBucket - 1) / bitsPerBucket)
}

func New(N uint) *DynamicSet {
	return &DynamicSet{
		N:       N,
		buckets: make([]uint8, bucketsFor(N)),
	}
}

func (s *DynamicSet) inRange(x uint) bool { return x <= s.N }
func (s *DynamicSet) idx(x uint) (int, uint) {
	return int(x / bitsPerBucket), x % bitsPerBucket
}

func (s *DynamicSet) Add(x uint) {
	if !s.inRange(x) {
		return
	}
	i, b := s.idx(x)
	s.buckets[i] |= uint8(1) << b
}

func (s *DynamicSet) Remove(x uint) {
	if !s.inRange(x) {
		return
	}
	i, b := s.idx(x)
	s.buckets[i] &^= uint8(1) << b
}

func (s *DynamicSet) Toggle(x uint) {
	if !s.inRange(x) {
		return
	}
	i, b := s.idx(x)
	s.buckets[i] ^= uint8(1) << b
}

func (s *DynamicSet) Contains(x uint) bool {
	if !s.inRange(x) {
		return false
	}
	i, b := s.idx(x)
	return s.buckets[i]&(uint8(1)<<b) != 0
}

func (s *DynamicSet) ClearAll() {
	for i := range s.buckets {
		s.buckets[i] = 0
	}
}

func (s *DynamicSet) SetAll() {
	for i := range s.buckets {
		s.buckets[i] = 0xFF
	}
	// Trim bits beyond N
	last := len(s.buckets) - 1
	high := (s.N % bitsPerBucket) + 1
	if high < bitsPerBucket {
		s.buckets[last] &= (1 << high) - 1
	}
}

func (s *DynamicSet) Count() int {
	sum := 0
	for _, b := range s.buckets {
		sum += bits.OnesCount8(b)
	}
	return sum
}

func (s *DynamicSet) ForEach(fn func(x uint)) {
	for x := uint(0); x <= s.N; x++ {
		if s.Contains(x) {
			fn(x)
		}
	}
}

func Union(a, b *DynamicSet) *DynamicSet {
	maxN := a.N
	if b.N > maxN {
		maxN = b.N
	}
	res := New(maxN)
	minBuckets := len(a.buckets)
	if len(b.buckets) < minBuckets {
		minBuckets = len(b.buckets)
	}
	for i := 0; i < minBuckets; i++ {
		res.buckets[i] = a.buckets[i] | b.buckets[i]
	}
	if len(a.buckets) > len(b.buckets) {
		copy(res.buckets[minBuckets:], a.buckets[minBuckets:])
	} else if len(b.buckets) > len(a.buckets) {
		copy(res.buckets[minBuckets:], b.buckets[minBuckets:])
	}
	return res
}

func Intersection(a, b *DynamicSet) *DynamicSet {
	minN := a.N
	if b.N < minN {
		minN = b.N
	}
	res := New(minN)
	minBuckets := len(a.buckets)
	if len(b.buckets) < minBuckets {
		minBuckets = len(b.buckets)
	}
	for i := 0; i < minBuckets; i++ {
		res.buckets[i] = a.buckets[i] & b.buckets[i]
	}
	return res
}

func (s *DynamicSet) String() string {
	var b strings.Builder
	b.WriteByte('{')
	for x := uint(0); x <= s.N; x++ {
		if s.Contains(x) {
			fmt.Fprintf(&b, "%d ", x)
		}
	}
	b.WriteByte('}')
	return b.String()
}

func main() {
	s := New(20)
	s.Add(2)
	s.Add(5)
	s.Toggle(7)
	fmt.Println("s:", s)                 // {2 5 7 }
	fmt.Println("count:", s.Count())     // e.g., 3
	fmt.Println("has 5?", s.Contains(5)) // true
	s.Remove(5)
	fmt.Println("has 5?", s.Contains(5)) // false

	a := New(12)
	b := New(18)
	a.Add(1)
	a.Add(3)
	a.Add(5)
	b.Add(3)
	b.Add(4)
	b.Add(10)
	b.Add(18)

	u := Union(a, b)
	i := Intersection(a, b)

	fmt.Println("a:", a) // {1 3 5 }
	fmt.Println("b:", b) // {3 4 10 18 }
	fmt.Println("a ∪ b:", u)
	fmt.Println("a ∩ b:", i)

	fmt.Print("iterate: ")
	i.ForEach(func(x uint) { fmt.Printf("%d ", x) })
	fmt.Println()
}