Table of Contents
Introduction & History
What is Go?
Go (often referred to as Golang to avoid ambiguity and because of its former domain name golang.org) is:
- A statically typed (declare variable type or inferred), compiled, high-level general-purpose programming language
- Often called "C for the 21st century"
- Popular choice for high-performance server-side applications
- The language that built tools like Docker, Kubernetes, CockroachDB, and Dgraph
Six Main Points About Go:
- Statically typed - Variables must be declared or type inferred
- Strongly typed - Cannot mix types (e.g., can't add number and string)
- Compiled - Source code compiled to machine code for performance
- Fast compile time - Innovations in dependency analysis enable extremely fast compilation
- Built-in concurrency - Goroutines allow functions to run simultaneously on multiple CPU threads
- Simplicity - Features like garbage collection make programming easier
Design Philosophy
Go was designed at Google in 2007 to improve programming productivity in an era of multicore, networked machines and large codebases. The designers wanted to address criticisms of other languages in use at Google while keeping their useful characteristics:
- Static typing and run-time efficiency (like C)
- Readability and usability (like Python)
- High-performance networking and multiprocessing
The designers were primarily motivated by their shared dislike of C++.
Key Features:
- Source code compiles down to machine code, which generally outperforms interpreted languages
- Fast compile times made possible by innovations in dependency analysis
- Package and module system makes it easy to import/export code between projects
- Pointers without pointer arithmetic - Store memory addresses safely without the dangerous and unpredictable behavior of pointer arithmetic
- Concurrency with goroutines - Functions can run simultaneously utilizing multiple CPU threads
About the Creators
Created at Google by:
- Robert Griesemer (Hotspot, JVM)
- Rob Pike (Unix, UTF-8)
- Ken Thompson (B, C, Unix, UTF-8)
Timeline
- 2005: First dual-core processors emerge
- 2006: Go development started
- 2007: Go designed at Google
- 2009: Open sourced on November 10, 2009
Fun Fact: In the Go playground, time begins at 2009-11-10 23:00:00 UTC - Go's birthday! The announcement was titled "Hey! Ho! Let's Go!" (a Ramones song reference).


Basics
Key Concepts
- Every Go program is made up of packages
- Programs start running in package main
- Files using a package start with
package <name>(e.g.,import "math/rand"→ files start withpackage rand) - Capitalized names are exported and can be used outside the package
- Constants cannot be declared using
:=syntax
Package Structure
package main
import "fmt"
func main() {
fmt.Println("Hello, World!")
}Environment
go env # View all environment variables
go env GOARCH GOOS # View architecture and OSBuild vs Install
go build: Compiles executable and moves it to destinationgo install: Does more - moves executable to$GOPATH/binand caches non-main packages to$GOPATH/pkgfor faster future compilation
Go Runtime
The Go runtime system manages:
- Goroutine management: Creates, destroys, and schedules goroutines
- Garbage collection: Automatically frees unused memory
- Memory management: Allocates and deallocates memory
- Channel management: Manages communication between goroutines
- Stack management: Each goroutine has its own stack
Data Types & Variables
Data Types
// Boolean
bool
// String
string
// Integers (unsigned)
uint uint8 uint16 uint32 uint64 uintptr
// Integers (signed)
int int8 int16 int32 int64
// Floating point
float32 float64
// Aliases
byte // alias for uint8
rune // alias for int32 (represents a Unicode code point)
// Complex numbers
complex64 complex128Variable Declaration
// Method 1: Declare then assign
var name string
name = "John"
// Method 2: Declare and initialize
var name string = "John"
// Method 3: Multiple variables
var b, c int = 1, 2
// Method 4: Type inference
var name = "John"
// Method 5: Short declaration (inside functions only)
name := "John"
// Outside a function, every statement begins with a keyword (var, func, etc.)
// so the := construct is not availableType Inference
%T // Format specifier to get type of variableConstants
const pi = 3.14
// Constants cannot be declared using := syntax
// var name := "test" // This works for variables
// const name := "test" // ERROR! Must use: const name = "test"
// Numeric constants are high-precision values
const d = 3e20 // 300,000,000,000,000,000,000Constant expressions perform arithmetic with arbitrary precision:
The value 3e20 equals 300,000,000,000,000,000,000. This number is HUGE - it's too big to fit in a standard int64 (which maxes out at around 9 quintillion).
However, Go allows you to define this as a constant because constants are untyped and have arbitrary precision until they're used in a context that requires a specific type. Only when you try to assign it to a typed variable would you get an error if it doesn't fit.
const bigNumber = 3e20 // OK - untyped constant
// var x int64 = bigNumber // ERROR - too big for int64
var y float64 = bigNumber // OK - fits in float64Input/Output Operations
Input (Scanning)
fmt.Scan
- Reads space-separated input from standard input
- Stops on first newline or whitespace
fmt.Scan(&a, &name)fmt.Scanln
- Reads input until newline (
\n) - Stops on newline instead of whitespace
fmt.Scanln(&a, &name)fmt.Scanf
- Reads formatted input using format specifiers (
%s,%d,%f) - Stops based on specified format
fmt.Scanf("%d %s", &a, &name)bufio.NewReader
- Advanced input reading capabilities
- Reads entire line including spaces
reader := bufio.NewReader(os.Stdin)
input, _ := reader.ReadString('\n') // Reads entire line including spacesOutput (Printing)
fmt.Print
- Prints values directly to console
- Concatenates values as strings
- No newline at end
fmt.Print("Hello", " ", "World!") // Output: Hello World!fmt.Println
- Prints values with newline at end
- Concatenates values with spaces between them
fmt.Println("Hello", "World!") // Output: Hello World!\nfmt.Printf
- Prints with formatting specifiers
- Uses
%s,%d,%v, etc. - No automatic newline (must specify
\n)
fmt.Printf("Name: %s, Age: %d\n", "Alice", 25)
fmt.Printf("%d %c\n", A[i], A[i]) // %d = ASCII value, %c = characterfmt.Sprint
- Concatenates values into string without formatting
- Doesn't print to console, returns string
- No newline
message := fmt.Sprint("Hello", " ", "World!")
fmt.Println(message)fmt.Sprintln
- Concatenates values into string with newline
- Returns string with newline
message := fmt.Sprintln("Hello", "World!")
fmt.Print(message)fmt.Sprintf
- Formats values into string using format specifiers
- Returns formatted string
- No newline unless explicitly added
message := fmt.Sprintf("Name: %s, Age: %d", "Alice", 25)
fmt.Println(message)fmt.Fprint
- Writes formatted output to specified writer (files, buffers, etc.)
import (
"os"
"fmt"
)
func main() {
fmt.Fprint(os.Stdout, "Hello World!")
}Control Flow
Conditionals
If-Else
if a < b {
// code
} else if a > c {
// code
} else {
// code
}
// Statement can precede conditional
if num := 9; num < 0 {
// num is available here and in all subsequent branches
}Note: There is no ternary operator in Go. You'll need to use a full if statement even for basic conditions.
Statement-Statement Idiom:
You can declare and initialize variables within the if statement itself:
if z := a; z > n {
// z is available in this scope
fmt.Println(z)
}
// z is not available hereComma-Ok Idiom:
Used to check if a key exists in a map or if a channel receive was successful:
// With maps
if val, ok := m[key]; ok {
// Key exists, use val
fmt.Println(val)
} else {
// Key doesn't exist
fmt.Println("Key not found")
}
// Shorthand
val, ok := m[key]
if !ok {
fmt.Println("Key not found")
}Switch
You can use commas to separate multiple expressions in the same case statement.
switch i {
case 1:
// code
case 2, 3, 4: // Multiple values
// code
case i < 10: // Conditional expression
// code
case 'a', 'e', 'i', 'o', 'u':
// code
case "yes":
// code
default:
// code
}
// Switch without expression (alternate if-else)
switch {
case a < 12:
fmt.Println("It's before noon")
default:
fmt.Println("It's after noon")
}Features:
- Can use commas to separate multiple expressions
defaultis optionalbreakandfallthroughkeywords available
Loops
For Loop
for i, j := 0, len(A)-1; i < j; i, j = i+1, j-1 {
// code
}Range Loop
Range loops provide a convenient way to iterate over elements in various data structures:
// Slice or Array
for i, v := range slice {
// i = index, v = value
fmt.Printf("Index: %d, Value: %d\n", i, v)
}
// Map
for k, v := range map {
// k = key, v = value
fmt.Printf("Key: %s, Value: %d\n", k, v)
}
// String (iterates over runes, not bytes)
for i, c := range "hello" {
// i = byte index (not rune index!), c = rune
fmt.Printf("Byte index: %d, Character: %c\n", i, c)
}
// Ignore index/key with _
for _, v := range slice {
fmt.Println(v)
}
// Just get index/key
for i := range slice {
fmt.Println(i)
}Functions
Basic Function
func addTwoNums(a, b int) int {
return a + b
}
result := addTwoNums(1, 2)Function Signature
func (r receiver) identifier(p parameters) (r returns) { code }
Anonymous Functions
An anonymous function is a function literal defined without a name.
Two ways to use anonymous functions:
- Immediate Invocation - Call it right where you define it
- Assignment to a Variable - Store it for later use
// Function literal assigned to variable
hypot := func(x, y float64) float64 {
return math.Sqrt(x*x + y*y)
}
fmt.Println(hypot(5, 12))
// Immediate invocation (no parameters)
func() {
fmt.Println("Hi!")
}()
// Immediate invocation with parameters
func(name string) {
fmt.Println("Hello,", name)
}("Alice")Variadic Functions
- Can be called with any number of trailing arguments. For example, fmt.Println is a common variadic function.
func dum(num ...int) {
fmt.Print(num) // Equivalent to []int
}
func main() {
dum(2, 4)
dum(1, 2, 3, 4, 5)
}Named Return Values
A return statement without arguments returns the named return values. This is known as a "naked" return.
func split(sum int) (x, y int) {
x = sum * 4 / 9
y = sum - x
return // "naked" return
}Note: Naked returns should only be used in short functions for readability.
Closures
A closure is a function that remembers variables from its creation environment. Closures are particularly useful when working with goroutines and callbacks.
func intSeq() func() int { // the Factory
i := 0 // Blueprint: "Every machine gets a private memory 'i' starting at 0."
// Blueprint: "The machine we build is a function that does this:"
return func() int {
i++ // Pressing the button adds 1 to its private 'i'.
return i // Then it returns the new value of 'i'.
}
}
f := intSeq()
fmt.Println(f()) // 1
fmt.Println(f()) // 2
fmt.Println(f()) // 3
g := intSeq()
fmt.Println(g()) // 1
fmt.Println(g()) // 2Understanding Closures: The Factory Analogy
Think of intSeq() as a factory that builds counter machines:
The Factory (intSeq function):
- Its only job is to build and return a new "counter" function.
The Private Memory (i variable):
- When the factory builds a counter, it gives it a private memory (
i) that starts at 0. - This memory is unique to each counter.
The Machine (the returned func()):
- This is the actual counter function. It has one job: add 1 to its private memory (
i) and return the new value. - It "closes over" or "remembers" its own
ibetween calls.
In Code:
f := intSeq()is ordering your first machine. It gets its own private memory.g := intSeq()is ordering a second, separate machine. It has its own memory and doesn't affect the first one.
Each time you call f(), it's like pressing a button on your first counter. Each time you call g(), it's like pressing a button on your second counter. They're independent!
Fibonacci Closure Example:
func fibonacci() func() int {
f1, f2 := 0, 1
return func() int {
f := f1
f1, f2 = f2, f+f2
return f
}
}
func main() {
f := fibonacci()
for i := 0; i < 10; i++ {
fmt.Println(f())
}
}Recursive Closures
Anonymous functions can also be recursive, but you need to declare the variable before defining the function.
var fib func(n int) int
// Must declare 'fib' variable first, then assign the function
// This is because the function body refers to 'fib' itself
fib = func(n int) int {
if n < 2 {
return n
}
return fib(n-1) + fib(n-2) // Calls itself
}
fib(10)Callback Functions
func doMath(a int, b int, f func(int, int) int) int {
return f(a, b)
}
func add(a, b int) int {
return a + b
}
doMath(1, 2, add)Defer
A defer statement defers function execution until surrounding function returns.
func main() {
fmt.Println("counting")
for i := 0; i < 3; i++ {
defer fmt.Println(i)
}
fmt.Println("done")
}
// Output:
// counting
// done
// 2
// 1
// 0Key Points:
- Arguments evaluated immediately, but function call deferred
- Deferred calls pushed onto stack (LIFO execution)
- Useful for cleanup operations
func demonstrateArgumentEvaluation() {
i := 10
fmt.Printf("1. Declaring 'i' with value: %d\n", i)
defer fmt.Printf("3. Deferred print. Value of 'i' is: %d\n", i)
i = 20
fmt.Printf("2. After changing 'i', its value is now: %d\n", i)
}
// Output:
// 1. Declaring 'i' with value: 10
// 2. After changing 'i', its value is now: 20
// 3. Deferred print. Value of 'i' is: 10Data Structures
Arrays
Arrays do not change in size.
var a [5]int // Declaration
var a = [2]int{1, 2} // Initialize
var a = [...]int{1, 2} // Length inferred
a := [5]int{1: 2, 4: 3} // Initialize specific elements
a := [5]int{1, 2} // Partially initialized
fmt.Println(a[0]) // Access element
fmt.Println(a) // Print array
a[0] = 3 // Change element
len(a) // Length
// Multi-dimensional
twoD := [2][3]int{
{1, 2, 3},
{4, 5, 6},
}Slices
Slices are dynamic in size.
var a []int // nil slice
primes := []int{2, 3, 5} // Slice literal
numbers := make([]int, 5) // make(type, length)
data := make([]string, 0, 10) // make(type, length, capacity)
len(s) // Length
cap(s) // Capacity
s = append(s, "e", "f") // Append
copy(c, s) // Copy
l := s[2:5] // Slice s[2], s[3], s[4]
s[:5] // Exclude s[5]
s[2:] // Include s[2]
a = a[:n-1] // Resize length
slices.Equal(t, t2) // Compare slices
// Remove element
a = append(a[:2], a[3:]...) // Second arg must be unpacked with ...Important: Slices are Reference Types
a := []int{1, 2, 3}
b := a
a[0] = 4
// b[0] is now 4 - b references the same underlying array!
// To create independent copy:
b := make([]int, len(a))
copy(b, a)Capacity and Underlying Array
The capacity of a slice is the number of elements in the underlying array, counting from the first element in the slice.
s := []int{2, 3, 5, 7, 11, 13} // len=6 cap=6
s = s[:4] // len=4 cap=6 (capacity unchanged)
s = s[2:] // len=2 cap=4 (capacity decreases!)Why capacity decreases: When you slice from index 2 onwards with s[2:], you're creating a new slice that starts at the 3rd element of the underlying array. The capacity is calculated from that starting point to the end of the underlying array. Since we're 2 elements from the start, capacity is reduced by 2 (from 6 to 4).
Important: Reslicing can extend a slice beyond its current length, up to its capacity:
s := []int{2, 3, 5, 7, 11, 13}
s = s[:0] // len=0 cap=6
s = s[:4] // len=4 cap=6 - can reslice up to capacity!2D Slices
Go doesn't have true 2D arrays. 2D slices are slices of slices.
// Create 2D slice (n rows, m columns)
a := make([][]int, n)
for i := 0; i < n; i++ {
a[i] = make([]int, m)
}
// Access elements
for i := 0; i < n; i++ {
for j := 0; j < m; j++ {
fmt.Scan(&a[i][j])
}
}
len(a) // Row length
len(a[0]) // Column lengthSorting Slices
sort.Ints(s) // Sort integers ascending
sort.Strings(s) // Sort strings ascendingMaps
Maps are unordered. Zero value is nil.
// Map literal
scores := map[string]int{
"Bob": 10,
"Alice": 9,
}
m := make(map[string]int) // Empty map
m := make(map[string]int, n) // With initial capacity
m["k1"] = 7 // Set value
len(m) // Length
delete(m, "k2") // Delete key/value
clear(m) // Remove all key/values
maps.Equal(n, n2) // Compare maps
// Comma-ok idiom
val, ok := m["k2"] // ok is true if key exists
if val, ok := m["k2"]; ok {
// key exists
}Omitting Type in Literals
If top-level type is just a type name, you can omit it from elements:
type Vertex struct {
Lat, Long float64
}
var m = map[string]Vertex{
"Bell Labs": {40.68433, -74.39967},
"Google": {37.42202, -122.08408},
}Strings
Strings are read-only slices of bytes encoded in UTF-8.
len(s) // Length
A[i] == 'a' // Check character
A[:len(A)-1] // Slice string
runes := []rune(A) // Convert to mutable rune slice (The standard Go idiom for building or modifying a string is to first convert it to a mutable slice of runes)
// String operations
s := "hey this is sk"
s1 := strings.Split(s, " ") // ["hey", "this", "is", "sk"] slice of strings [hey this is sk] but for A = " hello world " ["", "", "world", "", "", "hello", "", ""]
strings.Fields(A) // Handles multiple spaces - specifically designed to handle this. It treats one or more consecutive spaces as a single delimiter and automatically ignores any leading or trailing spaces.
strings.Join(A, " ") // Join with separator (hello world)
strings.Index(A, B) // Find substring index, gives index of b in a (aabaa, ba, 2)
// Range over strings
for i, c := range "go" {
// i is index, c is rune
}
// UTF-8 operations
import "unicode/utf8"
utf8.RuneCountInString(s) // Count runes, not bytesCharacter vs String
- Everything is binary. Characters use ASCII (e.g., 'a' = 97).
- In other languages, strings are made of “characters”. In Go, the concept of a character is called a rune - it’s an integer that represents a Unicode code point.
- strings are equivalent to []byte
var a rune // Rune declaration
A := "hello"
fmt.Printf("%d %c\n", A[i], A[i]) // %d = ASCII, %c = characterPointers
Go has no pointer arithmetic. Pointers are safer than in C/C++.
var p *int // Pointer to int
i := 10
p = &i // Address of i
fmt.Println(*p)// Read i through pointer (dereference)
*p = 21 // Set i through pointerPass by Value vs Reference
Everything in Go is passed by value unless it's a reference type:
- Pointers: A pointer holds the memory address of the value.
- Slices: A slice is a descriptor of an array segment. It includes a pointer to the array.
- Maps: A powerful data structure that associates values of one type with values of another type.
- Channels: Used for communication between goroutines.
- Functions and Interfaces.
func sliceChange(a []int) {
a[0] = 99
}
a := []int{1, 2, 3}
sliceChange(a)
fmt.Println(a[0]) // 99 - slice is reference type!Value vs Pointer Semantics
- When a function receives a value, it gets its own copy of that value. It will be typically placed in "THE STACK", which is fast and typically does not involve any form of garbage collection. Once the function returns, memory can be instantly reclaimed.
- When a function receives a pointer, it tells the compiler that this value could be shared across goroutine boundaries or it could be needed after the function call. So it will allocate it to "THE HEAP", which is more expensive and requires garbage collection.
- Try running go run -gcflags -m main.go (tells you if its moved to heap or not)
The Stack & The Heap
The Stack
- Stores local variables for functions/goroutines
- LIFO (Last-In-First-Out) structure
- Fast, automatic management
- Memory reclaimed when function returns
The Heap
- Stores variables with longer lifetime
- More flexible but slower
- Managed by garbage collector
- Used for shared or persistent data
Structs & Methods
Structs Basics
Structs are collections of fields. They are mutable.
package main
import "fmt"
type person struct {
name string
age int
}
type secretagent struct {
person // Nested struct (embedding)
ltk bool
}
func main() {
// Struct literal
p1 := person{
name: "Joe",
age: 28,
}
fmt.Println(p1)
fmt.Println(p1.name)
// Nested struct
sa1 := secretagent{
person: person{
name: "James B",
age: 35,
},
ltk: true,
}
fmt.Println(sa1)
fmt.Println(sa1.age) // Promoted field
// Anonymous struct
a := struct {
name string
}{
name: "SK",
}
fmt.Println(a)
}Struct Pointers
Pointers are automatically dereferenced. (*p).X notation is cumbersome.
p := &person{name: "Alice"}
fmt.Println(p.name) // Automatic dereferenceConstructors
- Constructors are special functions for reliably creating multiple instances of similar objects in a class-based OOP language.
- There is no specific definition for constructors in Go, but we can achieve the same functionality by writing a function (conventionally named
New...).
func newPerson(name string) *person {
p := person{name: name}
p.age = 42
return &p
}
func main() {
fmt.Println(newPerson("Jon")) // &{Jon 42}
}Abstraction
Defining behavior without implementation. This is the primary job of an interface. It defines a contract that other types can satisfy.
Constructor & Destructor
- Constructor: Go has no special constructor keyword. We use a factory function (by convention named
New...) to create and initialize our objects. - Destructor: Go is garbage-collected, so you don't manually destroy objects. For cleanup (like closing files), you use the
deferkeyword.
Methods
Go doesn't have classes, but you can define methods on types.
type rect struct {
width, height int
}
// Pointer receiver (can modify)
func (r *rect) area() int {
return r.width * r.height
}
// Value receiver (read-only)
func (r rect) perim() int {
return 2*r.width + 2*r.height
}
func main() {
r := rect{width: 10, height: 5}
fmt.Println("area: ", r.area()) // (&r).area() behind scenes
fmt.Println("perim:", r.perim())
rp := &r
fmt.Println("area: ", rp.area())
fmt.Println("perim:", rp.perim()) // (*rp).perim() behind scenes
}Method Sets
The method set of a type determines which interfaces it implements:
| Receiver Type | Can be called on |
|---|---|
| (t T) | T and *T |
| (t *T) | *T |
type circle struct {
radius float64
}
func (c *circle) area() float64 {
return 3.14 * c.radius * c.radius
}
type shape interface {
area() float64
}
func info(s shape) {
fmt.Println("Area", s.area())
}
func main() {
c := circle{5}
fmt.Println(c.area()) // Works: compiler adds &
info(&c) // Must pass pointer
// info(c) // Won't work with interface!
}Struct Sorting
Implement sort.Interface methods: Len(), Swap(), Less()
type Person struct {
Name string
Age int
}
type ByAge []Person
func (a ByAge) Len() int { return len(a) }
func (a ByAge) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a ByAge) Less(i, j int) bool { return a[i].Age < a[j].Age }
func main() {
people := []Person{
{"Bob", 31},
{"John", 42},
{"Michael", 17},
{"Jenny", 26},
}
fmt.Println(people)
// One can define a set of methods for the slice type, as with ByAge, and call sort.Sort.
sort.Sort(ByAge(people))
fmt.Println(people)
}Composition
- Go uses composition over inheritance. Embed structs to reuse code.
- The fields and methods of the embedded "inner" struct become accessible to the outer struct.
- This concept is similar to inheritance in traditional object-oriented languages, but Go does not have a built-in inheritance mechanism.
In Go there are no classes, you create a type!
type Engine struct {
// Engine fields
}
func (e *Engine) Start() {
fmt.Println("Engine started")
}
type Car struct {
Engine // Embedding
// Car-specific fields
}
func main() {
car := Car{}
car.Start() // Promoted method
}Interfaces
Interface Basics
type I interface {
M()
}
type T struct {
S string
}
// This method means T implements interface I
// No explicit declaration needed!
func (t T) M() {
fmt.Println(t.S)
}
func main() {
var i I = T{"hello"}
i.M()
}Implicit Implementation
Go uses duck typing: "If it walks like a duck and quacks like a duck, it's a duck."
type Shape interface {
Area() float64
}
type Circle struct {
Radius float64
}
func (c Circle) Area() float64 {
return math.Pi * c.Radius * c.Radius
}
type Rectangle struct {
Width, Height float64
}
func (r Rectangle) Area() float64 {
return r.Width * r.Height
}
// Polymorphism: accepts any type with Area() method
func printShapeInfo(s Shape) {
fmt.Printf("The area is %f\n", s.Area())
}
func main() {
c := Circle{Radius: 10}
r := Rectangle{Width: 5, Height: 4}
printShapeInfo(c) // Works!
printShapeInfo(r) // Works!
}Composition Over Inheritance
- Go favors composition over inheritance.
- In traditional OOP, you have inheritance: Dog -> Animal, Manager -> Employee
- Instead, Go favors composition over inheritance. You achieve this through embedding. Instead of saying a Manager is an Employee, you say a Manager has an Employee record inside it.
Example: Manager/Employee
type Employee struct {
Name string
ID string
}
func (e Employee) Display() {
fmt.Printf("Name: %s, ID: %s\n", e.Name, e.ID)
}
type Manager struct {
Employee // Embedding (not inheritance!)
Reports []string
}
func main() {
m := Manager{
Employee: Employee{Name: "Alice", ID: "E123"},
Reports: []string{"Bob", "Charlie"},
}
// Because Employee is embedded, we can access its fields and methods directly
m.Display() // Prints: Name: Alice, ID: E123
// This LOOKS like inheritance, but it's composition
// Manager doesn't inherit from Employee; it contains an Employee
}Understanding Interfaces and Polymorphism
- Polymorphism is the ability to treat different objects in the same way. In Java, you might have a Shape class and different subclasses (Circle, Square) that you can put into a List. (Value can be any type)
- Go achieves this with interfaces, but it does so implicitly. You don't have to declare "my type implements this interface." You just implement the required methods. This is often called "duck typing."
- Polymorphism means a variable of one type (an interface) can hold a value of another type (a struct).
The USB-C Port Analogy:
Think of an interface like a USB-C port on your laptop. The port doesn't care if you're plugging in a phone, tablet, or headphones - as long as the device has a USB-C connector, it will work.
// The interface (the USB-C port contract)
type Speaker interface {
Speak() string
}
// Dog implements Speaker
type Dog struct{}
func (d Dog) Speak() string {
return "Woof!"
}
// Cat implements Speaker
type Cat struct{}
func (c Cat) Speak() string {
return "Meow!"
}
// This function is polymorphic - it works with ANY type that implements Speaker
func MakeSound(s Speaker) {
fmt.Println(s.Speak())
}
func main() {
dog := Dog{}
cat := Cat{}
// Same function works with different types!
MakeSound(dog) // Prints: Woof!
MakeSound(cat) // Prints: Meow!
}How Go's Compiler Checks Interfaces (Duck Typing):
When Go sees MakeSound(dog):
- "The
MakeSoundfunction needs aSpeaker." - "The contract for
Speakeris a method calledSpeak() string." - "I'm being given a variable
dogof typeDog." - "Let me check the
Dogtype... Does it have a method calledSpeak() string? ...Yes, it does!" - "Great. That means
Dogimplicitly implementsSpeaker. The code is valid."
Key Point: In Go, you don't declare that you are fulfilling a contract. You simply prove it by having the right methods. If you have the methods, you implement the interface automatically.
Go's OOP Approach
While Go isn't a traditional object-oriented language, it provides its own unique tools to achieve the same goals.
| Goal | Traditional OOP | Go Way |
|---|---|---|
| Encapsulation | class with fields/methods | struct with methods |
| Code Reuse | Inheritance (is-a) | Composition (has-a) |
| Polymorphism | Explicit interfaces | Implicit interfaces (duck typing) |
| Hierarchy | Central concept | No hierarchies |
| Objects | Complex with constructors | Simple structs |
Go is OOP? Yes and no
Go is OOP? Yes and no — it provides its own unique tools to achieve the same goals.
Why People Say "Yes, Go is OOP-like"
- Encapsulation — Go supports encapsulation, which is a core pillar of OOP. Encapsulation is bundling data and the methods that operate on that data together.
- In Go, you use structs and methods to group data and behavior.
- Access control is handled at the package level (capitalization determines export), rather than with per-field access modifiers found in some OOP languages.
Why People Say "No, Go is not OOP"
- Class vs type: Go has no
classkeyword or class-based inheritance; instead you definetype(usually structs) and attach methods to them. - Object vs instance: An object in class-based languages is an instance of a class that bundles data and methods together; in Go you build similar constructs from structs + methods.
- Inheritance: Traditional inheritance (subclassing) is not part of Go's design. Code reuse is achieved using composition (embedding), not
extends. - Polymorphism: Achieved via implicit interfaces (duck typing) rather than class hierarchies—types satisfy interfaces by implementing methods.
- Abstraction: Interfaces provide abstraction, but Go's approach differs from classical OOP abstractions (no abstract classes).
Key Differences:
- No classes (use structs)
- No inheritance (use composition)
- No explicit interface implementation
- No constructors/destructors (use factory functions and defer)
Summary: Go is OOP-like in spirit but different in implementation. It achieves encapsulation, code reuse, and polymorphism through structs, composition, and implicit interfaces rather than traditional OOP mechanisms.
Empty Interface
- The empty interface
interface{}(oranyin Go 1.18+) can hold any type. - The interface type that specifies zero methods is known as the empty interface: An empty interface may hold values of any type. (Every type implements at least zero methods.) Empty interfaces are used by code that handles values of unknown type. For example, fmt.Print takes any number of arguments of type interface.
func describe(i interface{}) {
fmt.Printf("(%v, %T)\n", i, i)
}
func main() {
var i interface{}
describe(i) // (<nil>, <nil>)
i = 42
describe(i) // (42, int)
i = "hello"
describe(i) // (hello, string)
}Generics
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.
- Comparable is a useful constraint that makes it possible to use the == and != operators on values of the type. func Index[T comparable](s []T, x T) int
func Index[T comparable](s []T, x T) int {
for i, v := range s {
if v == x {
return i
}
}
return -1
}
func main() {
si := []int{10, 20, 15, -10}
fmt.Println(Index(si, 15)) // 2
ss := []string{"foo", "bar", "baz"}
fmt.Println(Index(ss, "bar")) // 1
}Generic Types
Before generics, you had to use interface{} and lose type safety:
// OLD way (no type safety)
type OldList struct {
next *OldList
val any // Can be anything
}
// NEW way (type safe)
type List[T any] struct {
next *List[T]
val T
}Understanding Generic Type Syntax:
Let's break down what type List[T any] struct means step by step:
// 1. 2. 3.
type List[T any] struct {
next *List[T] // 4.
val T // 5.
}type List: We are declaring a new type namedList.[T]- The Type Parameter: This is the core of generics.Tis a placeholder for a real type that will be provided later. You can think of it as a variable, but for types. (You could call itValueType,Element, etc., butTis conventional.)any- The Constraint: This defines the "rules" for whatTis allowed to be. Theanyconstraint is the most permissive: it meansTcan be any type at all.next *List[T]: This means thenextfield must be a pointer to anotherListthat holds the exact same typeT. You can't link aListof integers to aListof strings. This enforces type safety throughout your data structure.val T: The value stored in this list node will have the typeT. If you create aListof ints,valwill be anint. If you create aListof strings,valwill be astring.
In simple terms:
type List[T any]means "I am defining a type calledListthat can work on any type, and we'll call that placeholder typeTfor now."
Why this matters:
Before generics, when you retrieved a value from OldList, Go only knew its type was any. You had to manually check its real type ("is this an int or a string?") and convert it. This is clumsy and can lead to errors at runtime if you guess wrong.
With generics, the type is known at compile time, giving you full type safety and better performance.
Type Constraints
// Basic constraint
func addT[T int|float64](a, b T) T {
return a + b
}
// Type set interface
type nums interface {
int | float64
}
func addT[T nums](a, b T) T {
return a + b
}Underlying Type Constraints
Use ~ to include all types with an underlying type:
type nums interface {
~int | ~float64 // Includes custom types with int/float64 as underlying type
}
func addT[T nums](a, b T) T {
return a + b
}
type blah int
func main() {
var a blah = 10
fmt.Println(addT(a, 2)) // Works with ~int
}Concurrency
"Concurrency is not parallelism." - Rob Pike
Concurrency vs Parallelism
Concurrency:
- Design pattern for code that can execute multiple tasks independently
- Potential for simultaneous execution
- Achieved with goroutines and channels
Parallelism:
- Actually executing multiple tasks at the same time
- Requires multiple CPUs/cores
- Runtime decides when to use parallelism
Sequential Execution:
- Opposite of parallel
- One task at a time in predefined order
Goroutines & WaitGroups
Goroutines are lightweight threads managed by the Go runtime. A WaitGroup is used to wait for a collection of goroutines to finish executing.
package main
import (
"fmt"
"sync"
"runtime"
)
var wg sync.WaitGroup
func foo() {
for i := 0; i < 3; i++ {
fmt.Println("foo:", i)
}
}
func bar() {
for i := 0; i < 3; i++ {
fmt.Println("bar:", i)
}
wg.Done()
}
func main() {
foo()
wg.Add(1)
fmt.Println("OS", runtime.GOOS)
fmt.Println("ARCH", runtime.GOARCH)
fmt.Println("CPU", runtime.NumCPU())
fmt.Println("Goroutines", runtime.NumGoroutine()) // 1
go bar() // Launch goroutine
fmt.Println("Goroutines", runtime.NumGoroutine()) // 2
wg.Wait() // Wait for goroutine to finish
}foo: 0
foo: 1
foo: 2
OS linux
ARCH amd64
CPU 1
Goroutines 1
Goroutines 2
bar: 0
bar: 1
bar: 2Race Conditions
package main
import (
"fmt"
"runtime"
"sync"
)
func main() {
fmt.Println("Goroutines:", runtime.NumGoroutine())
counter := 0 // shared memory for all goroutines
var wg sync.WaitGroup
wg.Add(5)
for i := 0; i < 5; i++ {
go func() { // function literal/anonymous function
v := counter
runtime.Gosched() // pause the current goroutine and give another goroutine a chance to run.
v++
counter = v
wg.Done()
}()
fmt.Println("Goroutines:", runtime.NumGoroutine())
}
wg.Wait()
fmt.Println("Goroutines:", runtime.NumGoroutine())
fmt.Println("count:", counter)
}go run -race main.go # -race will tell you if there is any race condition (Found: 1 data race)
Goroutines: 1
Goroutines: 2
Goroutines: 3
Goroutines: 4
Goroutines: 5
Goroutines: 6
Goroutines: 1
count: 1Mutex (Preventing Race Conditions)
How to prevent race conditions? Answer: Use a Mutex.
- When a goroutine accesses shared memory, it should lock it so it cannot be accessed by other goroutines simultaneously.
func main() {
fmt.Println("Goroutines:", runtime.NumGoroutine())
counter := 0
var wg sync.WaitGroup
var mu sync.Mutex
wg.Add(5)
for i := 0; i < 5; i++ {
go func() {
mu.Lock() // Lock prevents other goroutines from accessing shared memory
v := counter
runtime.Gosched() // pause/yield the current goroutine and give another goroutine a chance to run.
v++
counter = v
mu.Unlock()
wg.Done()
}()
fmt.Println("Goroutines:", runtime.NumGoroutine())
}
wg.Wait()
fmt.Println("Goroutines:", runtime.NumGoroutine())
fmt.Println("count:", counter)
}Goroutines: 1
Goroutines: 2
Goroutines: 3
Goroutines: 4
Goroutines: 5
Goroutines: 6
Goroutines: 1
count: 5Channels
"Don't communicate by sharing memory; share memory by communicating."
Won't work
func main() {
c:=make(chan int) // unbuffered channel
c<- 42 // main goroutine blocks here because it's waiting for a receiver
fmt.Println(<-c)
}
// deadlock: all goroutines are asleepFix: Goroutine
Unbuffered Channels
func main() {
c := make(chan int)
go func() {
c <- 42 // Send
}()
fmt.Println(<-c) // Receive
}Buffered Channels
func main() {
c := make(chan int, 1) // Buffer size 1
c <- 42 // Won't block
fmt.Println(<-c)
}Directional Channels
// Send-only
func foo(c chan<- int, wg *sync.WaitGroup) {
c <- 30
wg.Done()
}
// Receive-only
func bar(c <-chan int, wg *sync.WaitGroup) {
fmt.Println(<-c)
wg.Done()
}
func main() {
c := make(chan int)
var wg sync.WaitGroup
wg.Add(2)
go foo(c, &wg)
go bar(c, &wg)
wg.Wait()
}Range Over Channels
func foo(c chan<- int, wg *sync.WaitGroup) {
for i := 0; i < 5; i++ {
c <- i
}
close(c) // Must close or deadlock!
wg.Done()
}
func bar(c <-chan int, wg *sync.WaitGroup) {
for v := range c { // Reads until closed
fmt.Println(v)
}
wg.Done()
}Select
The select statement lets a goroutine wait on multiple communication operations. It blocks until one of its cases can proceed, then executes that case. If multiple cases are ready, it chooses one at random.
Select statement waits on multiple channel operations: "I am ready to receive from odd, even, OR quit. I will proceed with whichever one sends me a value first."
func send(odd, even, quit chan<- int) {
for i := 1; i <= 5; i++ {
if i%2 == 0 {
even <- i
} else {
odd <- i
}
}
quit <- 0
}
func receive(odd, even, quit <-chan int) {
for {
select {
case v := <-odd:
fmt.Println("Odd:", v)
case v := <-even:
fmt.Println("Even:", v)
case v := <-quit:
fmt.Println("Quit:", v)
return
}
}
}
func main() {
odd := make(chan int)
even := make(chan int)
quit := make(chan int)
go send(odd, even, quit)
receive(odd, even, quit)
}Fan-In Pattern
Taking values from many channels and putting them onto one channel.
func send(odd, even chan<- int) {
for i := 1; i <= 5; i++ {
if i%2 == 0 {
even <- i
} else {
odd <- i
}
}
close(even)
close(odd)
}
func receive(odd, even <-chan int, fanin chan<- int) {
var wg sync.WaitGroup
wg.Add(2)
go func() {
for v := range odd {
fanin <- v
}
wg.Done()
}()
go func() {
for v := range even {
fanin <- v
}
wg.Done()
}()
wg.Wait()
close(fanin)
}
func main() {
odd := make(chan int)
even := make(chan int)
fanin := make(chan int)
go send(odd, even)
go receive(odd, even, fanin)
for v := range fanin {
fmt.Println(v)
}
}Rob pike - https://go.dev/talks/2012/concurrency.slide#28
Fan-Out Pattern
The fan-out pattern distributes work across multiple goroutines to process tasks concurrently, then gathers (fans-in) the results. This pattern is useful for parallelizing CPU-intensive operations.
Distributing work across multiple goroutines and gathering results:
func populate(c1 chan<- int) {
for i := 1; i <= 5; i++ {
c1 <- i
}
close(c1)
}
func fanOutIn(c1, c2 chan int) {
var wg sync.WaitGroup
for v := range c1 {
wg.Add(1)
go func(value int) {
c2 <- doSomething(value)
wg.Done()
}(v)
}
wg.Wait()
close(c2)
}
func doSomething(v int) int {
return v + 10
}
func main() {
c1 := make(chan int)
c2 := make(chan int)
go populate(c1)
go fanOutIn(c1, c2)
for v := range c2 {
fmt.Println(v)
}
}In short: You Fan-Out to do work faster, and then Fan-In to gather all the results.
Context
- Context is tool that you can use with concurrent design patterns.
- In Go servers, each incoming request is handled in its own goroutine. Request handlers often start additional goroutines to access backends such as databases and RPC services. The set of goroutines working on a request typically needs access to request-specific values such as the identity of the end user, authorization tokens, and the request’s deadline. When a request is canceled or times out, all the goroutines working on that request should exit quickly so the system can reclaim any resources they are using. https://go.dev/blog/context
Error Handling
Go's Error Philosophy
- Go avoids exceptions for more readable and predictable code.
- In many languages, exceptions are like hidden "gotos." A function can suddenly stop and jump to a catch block far away, making the code's flow difficult to follow.
- Instead, Go treats errors as regular values. A function that might fail simply returns the error alongside its normal result.
- This explicit error handling makes code more verbose but also more transparent and easier to reason about.
Error Interface
type error interface {
Error() string
}Error Handling Options
// fmt.Println - simple print
fmt.Println(err)
// log.Println - prints with date/time
log.Println(err)
// log.Fatalln - exits program
log.Fatalln(err) // Deferred functions do NOT run
// log.Panicln - stops current goroutine
log.Panicln(err) // Deferred functions run
// panic - stops normal execution
panic(err) // Deferred functions runCreating Errors
import (
"errors"
"fmt"
)
// errors.New
func isTrue(b bool) (bool, error) {
if !b {
return false, errors.New("It was False")
}
return true, nil
}
// fmt.Errorf
func isTrue(b bool) (bool, error) {
if !b {
return false, fmt.Errorf("It was False")
}
return true, nil
}Custom Error Types
type myError struct {
time int
err error
}
func (e myError) Error() string {
return fmt.Sprintf("%v Error dude :( %v", e.time, e.err)
}
func isTrue(b bool) (bool, error) {
if !b {
return false, myError{10, fmt.Errorf("It was False")}
}
return true, nil
}Error Handling Idiom
_, err := fmt.Println("hello")
if err != nil {
fmt.Println(err)
}Logging to File
func main() {
f, err := os.Create("log.txt")
if err != nil {
log.Fatal(err)
}
defer f.Close()
log.SetOutput(f)
f2, err := os.Open("no-file.txt")
if err != nil {
log.Fatalln(err) // Writes to log.txt and exits
}
defer f2.Close()
}Recover
Built-in function to regain control of panicking goroutine. Only useful in deferred functions.
func main() {
f()
fmt.Println("Returned normally from f.")
}
func f() {
defer func() {
if r := recover(); r != nil {
fmt.Println("Recovered in f", r)
}
}()
fmt.Println("Calling g.")
g(0)
fmt.Println("Returned normally from g.")
}
func g(i int) {
if i > 3 {
fmt.Println("Panicking!")
panic(fmt.Sprintf("%v", i))
}
defer fmt.Println("Defer in g", i)
fmt.Println("Printing in g", i)
g(i + 1)
}Output:
Calling g.
Printing in g 0
Printing in g 1
Printing in g 2
Printing in g 3
Panicking!
Defer in g 3
Defer in g 2
Defer in g 1
Defer in g 0
Recovered in f 4
Returned normally from f.
Testing
Test File Structure
- Tests must be in files ending with
_test.go - Must be in same package
- Function signature:
func TestXxx(t *testing.T)
// main.go
func sum(a, b int) int {
return a + b
}
// main_test.go
func TestSum(t *testing.T) {
if sum(2, 3) != 5 {
t.Error("Expected", 5, "Got", sum(2, 3))
}
}go test
PASS
ok test 3.459s
--- FAIL: TestSum (0.00s)
main_test.go:7: Expected 5 Got 6
FAIL
exit status 1
FAIL test 4.616sTable-Driven Tests
The idiomatic way to test multiple cases:
type testcase struct {
data []int
answer int
}
func TestSum(t *testing.T) {
tests := []testcase{
{
data: []int{2, 3},
answer: 5,
},
{
data: []int{7, 3},
answer: 10,
},
}
for _, tc := range tests {
result := sum(tc.data[0], tc.data[1])
if result != tc.answer {
t.Errorf("Expected %d, got %d", tc.answer, result)
}
}
}Benchmarking
Benchmarks run code repeatedly to get stable average execution time.
var result int
func BenchmarkSum(b *testing.B) {
for i := 0; i < b.N; i++ {
result = sum(1, 2, 3, 4, 5)
}
}Run benchmarks: go test -bench .
Output explanation:
go test -bench .
goos: windows
goarch: amd64
pkg: test
cpu: 12th Gen Intel(R) Core(TM) i5-1245U
BenchmarkSum-12 305967357 4.197 ns/op 0 B/op 0 allocs/op
PASS
ok test 3.106sBenchmarkSum-12: Test name and number of CPUs305967357: Number of iterations run4.197 ns/op: Average time per operation0 B/op: Bytes allocated per operation0 allocs/op: Number of allocations per operation
Test Coverage
Check what percentage of your code is tested:
go test -cover
PASS
coverage: 80.0% of statements
ok test 9.639sGenerate detailed coverage report:
go test -coverprofile=coverage.out
go tool cover -html=coverage.outLinting and Code Quality
Tools to improve code quality:
go fmt ./... # Format code automatically
go vet ./... # Report suspicious constructs
golint ./... # Report poor coding style (install separately)go fmt: Automatically formats your code according to Go standards.
go vet: Examines Go source code and reports suspicious constructs (unreachable code, improper use of range, etc.).
golint: Reports style mistakes (exported functions without comments, naming conventions, etc.).
Standard Library & Packages
Common Packages
fmt // Formatting I/O
math // Mathematical functions
strings // String manipulation
sort // Sorting
time // Time/date operations
os // OS interface
io // I/O primitives
bufio // Buffered I/O
errors // Error creation
log // Logging
sync // Synchronization primitivesMath Package
math.Floor(x) // Greatest integer ≤ x
math.Ceil(x) // Least integer ≥ x
math.Round(x) // Nearest integer
math.Pow(x, y) // x^y
math.Pi // 3.14159265...
math.MinInt64 // -9,223,372,036,854,775,808Strings Package
strings.Split(s, " ") // Split string
strings.Fields(s) // Split on whitespace
strings.Join(slice, " ") // Join with separator
strings.Index(s, substr) // Find substringInit Function
Called after variable declarations, after all imported packages initialized:
func init() {
if user == "" {
log.Fatal("$USER not set")
}
}Note: init is a niladic function (Function does not have an argument).
Advanced Concepts
UTF-8 Encoding
- UTF-8 is an encoding system for Unicode
- Variable length: 1-4 bytes per character
- ASCII characters: 1 byte
- Most common approach: efficient memory use
| Concept | What it is | How it works |
|---|---|---|
| ASCII | Basic keyboard for English | 128 characters (7 bits) |
| Unicode | Universal character catalog | Unique number for every character/emoji/symbol |
| UTF-8 | Smart storage method | Variable 1-4 bytes per character |
SHA256 Checksum
- To check that what was received is what was intended. shasum -a 256 path/to/file 24325uib3654356rhrh897r8h78rthrthr Verify file integrity:
shasum -a 256 path/to/fileCharacter Increment
var c rune = 'A'
c++ // 'B'
// Example pattern
// n=4
// A
// B B
// C C C
// D D D DDocumentation (godoc)
godoc -http :8080 # Run local doc serverComments above functions generate documentation. Also use doc.go files.
Stringer Interface
The Stringer interface allows types to describe themselves as strings. The fmt package (and many others) looks for this interface when printing values.
// Defined in fmt package
type Stringer interface {
String() string
}Example implementation:
package main
import (
"fmt"
"strconv"
)
type book struct {
title string
}
func (b book) String() string {
return fmt.Sprint("The title of the book is ", b.title)
}
type count int
func (c count) String() string {
return fmt.Sprint("This is the number ", strconv.Itoa(int(c)))
}
func main() {
b := book{
title: "West With The Night",
}
var n count = 42
// Both print using their custom String() methods
fmt.Println(b) // The title of the book is West With The Night
fmt.Println(n) // This is the number 42
}Writer Interface
The io.Writer interface is one of the most important interfaces in Go's standard library. It represents any type that can write bytes.
// Defined in io package
type Writer interface {
Write(p []byte) (n int, err error)
}Example usage:
package main
import (
"bytes"
"fmt"
"io"
"log"
"os"
)
type person struct {
first string
}
// writeOut accepts any type that implements io.Writer
func (p person) writeOut(w io.Writer) {
w.Write([]byte(p.first))
}
func main() {
p := person{
first: "Jenny",
}
// Write to a file
f, err := os.Create("output.txt")
if err != nil {
log.Fatalf("error %s", err)
}
defer f.Close()
// Write to a buffer
var b bytes.Buffer
// Same method works with both file and buffer!
p.writeOut(f) // Writes to file
p.writeOut(&b) // Writes to buffer
fmt.Println(b.String()) // Prints: Jenny
}Why this is powerful:
- Your code doesn't care if it's writing to a file, network connection, or in-memory buffer
- Any type that implements
Write([]byte) (int, error)can be used - This is polymorphism in action
Summary
Go is a modern, statically-typed language designed for:
- Simplicity: Clean syntax, easy to learn
- Concurrency: Built-in goroutines and channels
- Performance: Compiled, efficient execution
- Reliability: Strong typing, garbage collection
- Practicality: Great standard library, fast compilation
Core Philosophy:
- Less is more (minimalist design)
- Composition over inheritance
- Explicit over implicit
- Concurrency is not parallelism
- Don't communicate by sharing memory; share memory by communicating
Learn More: