Files
go-simple-api/lessons/00-go-basics-2-functions-structs-pointers.md
T
2026-07-16 10:13:46 +03:30

9.7 KiB

Go Basics, Part 2 — Functions, Structs, Methods, and Pointers

This continues directly from Part 1. By the end of this lesson you'll understand every syntactic shape used in the main course's handler and repository code.

1. Functions

package main

import "fmt"

// A function with two parameters (both int) and one return value (int).
// Parameters sharing a type can share the type annotation: "a, b int"
// means both a and b are int.
func add(a, b int) int {
	return a + b
}

func main() {
	sum := add(3, 4)
	fmt.Println(sum) // 7
}

Multiple return values — used constantly in Go

This is one of Go's most distinctive features, and you'll see it on almost every line of real Go code, especially for error handling:

func divide(a, b int) (int, error) {
	if b == 0 {
		return 0, fmt.Errorf("cannot divide by zero")
	}
	return a / b, nil
}

func main() {
	result, err := divide(10, 2)
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Println("result:", result)
}

The pattern value, err := someFunc() followed immediately by if err != nil { ... } is THE dominant idiom in Go. You will type this exact shape hundreds of times in the main course. There are no exceptions / try-catch in Go (with one narrow exception, panic/recover, which we'll touch on briefly later) — errors are just regular return values that you're expected to check every time.

nil is Go's "no value" — similar to null in other languages. It's the zero value for pointers, interfaces, slices, maps, channels, and function types. error is an interface (explained in Part 3), so nil is its zero value too — "no error occurred."

Named return values (used occasionally, good to recognize)

func divide(a, b int) (result int, err error) {
	if b == 0 {
		err = fmt.Errorf("cannot divide by zero")
		return
	}
	result = a / b
	return
}

result and err are declared as part of the function signature; a bare return sends back their current values. You won't write much code this way in this course, but you'll see it in standard-library source if you ever go looking.

Anonymous functions and closures

A function can be defined without a name and assigned to a variable, or passed directly as an argument:

square := func(n int) int {
	return n * n
}
fmt.Println(square(5)) // 25

A closure is an anonymous function that "remembers" variables from the scope it was created in, even after that outer function has returned:

func makeCounter() func() int {
	count := 0
	return func() int {
		count++
		return count
	}
}

func main() {
	counter := makeCounter()
	fmt.Println(counter()) // 1
	fmt.Println(counter()) // 2
	fmt.Println(counter()) // 3 - count persists between calls!
}

This is important: makeCounter returns a function, and that returned function still has access to count, which technically belongs to makeCounter's own (finished) execution. This exact mechanism is what makes Go's HTTP middleware pattern work — you'll see functions that take some setup arguments and return another function, three layers deep, all throughout the main course (starting in Lesson 2). Understanding this closure example is the key to understanding that pattern.

2. Structs — Go's way of grouping data

Go doesn't have classes. Instead, it has structs: named groups of fields.

package main

import "fmt"

type User struct {
	Name string
	Age  int
}

func main() {
	// Construct a struct with a "struct literal"
	u := User{
		Name: "Hamid",
		Age:  31,
	}

	fmt.Println(u.Name, u.Age) // access fields with dot notation
	u.Age = 32                 // fields are mutable
	fmt.Println(u.Age)

	// You can also build one without field names, in declared order
	// (works, but fragile - prefer named fields)
	u2 := User{"Sara", 28}
	fmt.Println(u2)
}

Capitalization matters: exported vs. unexported

This is one of Go's most important and most beginner-surprising rules:

Any identifier (variable, function, type, struct field...) that starts with an UPPERCASE letter is "exported" — visible outside its package. Anything starting lowercase is "unexported" — private to its own package.

There's no public/private keyword. Capitalization IS the access control.

type User struct {
	Name string // exported - visible to other packages
	age  int    // unexported - only visible inside THIS package
}

You'll see this constantly in the main course: struct fields like models.User.Email are capitalized (need to be readable/settable from handlers), while helper functions like getEnv in the config package are lowercase (only used internally, no other package needs them).

Struct tags — metadata attached to fields

type LoginRequest struct {
	Email    string `json:"email"`
	Password string `json:"password"`
}

The text in backticks after a field is a struct tag — a string of metadata that other packages can read via reflection. encoding/json (the standard library's JSON package) reads the json:"..." tag to know "the JSON key email maps to this Go field," regardless of the Go field name's capitalization. We use this on nearly every request/response struct in the main course.

3. Pointers (* and &) — the single most important concept to nail down

Every variable lives somewhere in memory, at an address. A pointer is a variable whose value IS a memory address — it "points to" where another variable lives.

  • &x — "give me the address of x" (turns a value into a pointer to it)
  • *T (in a type position) — means "a pointer to a T", e.g. *int, *User
  • *p (in an expression) — "dereference p": go to the address it holds and read/write the value stored there
package main

import "fmt"

func main() {
	x := 10
	p := &x // p is a pointer to x; p holds x's memory address

	fmt.Println(x)  // 10
	fmt.Println(p)  // something like 0xc0000140a0 - an address
	fmt.Println(*p) // 10 - dereferencing p gives x's value back

	*p = 20 // dereference p, then assign through it - changes x itself!
	fmt.Println(x) // 20
}

Why pointers matter: Go passes everything by value

When you pass a variable to a function, the function receives a copy. If you want a function to actually modify the caller's variable, you must pass a pointer, and the function must dereference it to write through.

func double(n int) {
	n = n * 2 // only changes the LOCAL COPY
}

func doublePtr(n *int) {
	*n = *n * 2 // dereferences and changes the ORIGINAL
}

func main() {
	x := 5
	double(x)
	fmt.Println(x) // 5 - unchanged!

	doublePtr(&x)
	fmt.Println(x) // 10 - changed
}

Pointers to structs, and why the main course uses them everywhere

type Book struct {
	Title string
	Pages int
}

func addPages(b *Book, extra int) {
	b.Pages += extra // note: no need to write (*b).Pages, Go allows b.Pages directly
}

func main() {
	book := Book{Title: "Go 101", Pages: 100}
	addPages(&book, 50)
	fmt.Println(book.Pages) // 150
}

Note b.Pages instead of (*b).Pages — Go automatically dereferences struct pointers for field access, as a convenience. Both work; everyone writes b.Pages.

Two big reasons the main course uses pointers to structs constantly:

  1. Writing a result back into the caller's variable. E.g. after inserting a new row into the database, we want to write the newly generated ID back into the struct the caller already has — that only works if the function received a pointer.
  2. Sharing one instance instead of copying it. Things like a database connection pool or a logger should be ONE shared instance used everywhere, not copied every time they're passed around. That's why sql.Open returns *sql.DB, not sql.DB — every part of the app needs to share the exact same pool.

Methods and receivers

A method is a function attached to a specific type, via a receiver declared between func and the method name:

type Counter struct {
	count int
}

// Value receiver - c is a COPY of the Counter this method was called on.
func (c Counter) Value() int {
	return c.count
}

// Pointer receiver - c is the ADDRESS of the real Counter.
func (c *Counter) Increment() {
	c.count++ // modifies the REAL struct, not a copy
}

func main() {
	c := Counter{}
	c.Increment()
	c.Increment()
	fmt.Println(c.Value()) // 2
}

Rule of thumb, used throughout the main course: if a method needs to modify the struct, or the struct holds a resource like a database connection, use a pointer receiver (*Counter). If the struct is small and the method is purely read-only, a value receiver is fine — but pointer receivers are the overwhelming default for anything nontrivial, and that's what you'll see almost everywhere in this project (e.g. every method on *UserRepository, *AuthHandler).

Go automatically inserts the & for you when calling a pointer-receiver method on an addressable value — c.Increment() is really (&c).Increment() behind the scenes. You don't need to write that & yourself; just know it's happening.

4. Try it yourself

New scratch folder, go mod init practice2:

  1. Define a Book struct with Title string, Author string, and Read bool.
  2. Write a function NewBook(title, author string) *Book that constructs and returns a pointer to a Book (this is the exact "constructor" pattern used throughout the main course — NewXxx returning *Xxx).
  3. Write a method func (b *Book) MarkAsRead() that sets Read = true.
  4. In main, create a book with NewBook, call MarkAsRead() on it, and print the struct with fmt.Printf("%+v\n", book) to confirm Read is now true.

Once this feels solid, move to Part 3 — interfaces, error handling, slices/maps, packages, and a first look at goroutines and JSON, which rounds out everything you need for Lesson 1.