golang

Tags :: Language

Install

See site if not on mac w/ homebrew, otherwise

brew install go

Access all tools with go.

  • compiler: go build
  • code formatter: go fmt
  • dependency manager: go mod
  • test runner: go test
  • mistake scanner: go vet

Hello world

Go code runs in modules.

Initialize module

Within a new directory, initialize a module with

go mod init your/module

This will create a new file go.mod

First program

Code is located in main.go

package main

import "fmt"

func main() {
    fmt.Println("Hello World")
}

Run in the same directory as main.go with

go run main.go

Types

Type Category Description
Zero Predeclared Default value assigned to declared but unassigned variables
Literals Predeclared Explicitly specified values
Integer Literals Base 10 by default
Floating-point Literals Uses decimal point for fractions
Rune Literals Single character representation
String Literals Two creation methods
Booleans Predeclared Binary true/false values
byte Integer Alias for uint8
int Integer Signed integer sized to architecture’s word size
uint Integer Unsigned integer sized to architecture’s word size
rune Integer Alias for int32
uintptr Integer Integer type for storing pointer values
String Predeclared Immutable sequence of bytes, zero value is empty string

Type conversion

Go does not support automatic type conversion. All variable types need to be converted to interact.

var x int = 10
var y float64 = 30.2
var sum1 float64 = float64(x) + y
var sum2 int = x + int(y)
fmt.Println(sum1, sum2) // 40.2  40

Slices

Core Concepts

  • Zero value: nil
  • Only comparable with nil and ==
  • Modifying shared slices affects all references
  • Length can differ from capacity

Common Operations

Creation

var s []int             // nil slice
s := []int{1, 2, 3}     // slice literal
s := make([]int, 5)     // length 5, capacity 5
s := make([]int, 0, 10) // length 0, capacity 10

Modification

s = append(s, 10)           // single element
s = append(s, 1, 2, 3)      // multiple elements
s = append(s, other...)     // append slice
clear(s)                    // zero all elements (Go 1.21+)

Slicing

s[low:high]     // items low through high-1
s[low:]         // items low through end
s[:high]        // items beginning through high-1
s[:]            // copy of entire slice
s[low:high:max] // control capacity of new slice

Copying

copy(dest, src)        // copies min(len(dest), len(src)) elements
copy(dest, src[1:])    // copy from middle of src

Array Conversion

array := [4]int{1, 2, 3, 4}
slice := array[:]              // array to slice
array2 := [4]int(slice)       // slice to array (new memory)
arrayPtr := (*[4]int)(slice)  // slice to array pointer (shared memory)

Best Practices

When to Use What

  1. var declaration (nil slice): When size unknown, no initial data
  2. Slice literal: When starting values are known
  3. make:
    • With length: For buffers or exact size known
    • Zero length + capacity: For growing slices

Memory Management

  • Capacity doubles when < 256
  • Growth slows to ~25% for larger slices
  • Use full slice expression s[low:high:max] to prevent capacity issues
  • Be careful with shared memory when slicing

Standard Library

  • slices.Equal(a, b): Compare if length and elements match
  • slices.EqualFunc(a, b, fn): Custom comparison function

Maps

Maps are declared as map[keyType]valueType. A nil map (e.g., var m map[string]int) cannot be written to. Use make or a map literal for writable maps.

// Map literal
m := map[string]int{"Alice": 10, "Bob": 20}

// Using make
m := make(map[string]int, 10) // Capacity 10
m["Charlie"] = 30

Structs

Structs are user-defined types with fields. Define them with type and access fields using dot notation.

type Person struct {
    Name string
    Age  int
}
p := Person{Name: "Alice", Age: 30}
fmt.Println(p.Name)

Anonymous structs are inline struct definitions:

pet := struct {
    Name string
    Kind string
}{Name: "Fido", Kind: "dog"}

Pointers

Use & to get a pointer and * to dereference. nil pointers cannot be dereferenced.

x := 10
p := &x
fmt.Println(*p) // 10

Shadowing

Inner-scope variables can shadow outer-scope ones:

x := 10
if x > 5 {
    x := 5
    fmt.Println(x) // 5
}
fmt.Println(x) // 10

Control Flow

if

Go’s if statements do not require parentheses. You can declare variables scoped to the condition:

if n := rand.Intn(10); n > 5 {
    fmt.Println("Big:", n)
} else {
    fmt.Println("Small:", n)
}

for

The for loop supports:

  1. Complete (C-style):

    • Initialization, condition, increment:
    for i := 0; i < 10; i++ {
        fmt.Println(i)
    }
    
  2. Condition only:

    • Equivalent to while:
    i := 1
    for i < 10 {
        fmt.Println(i)
        i++
    }
    
  3. Infinite:

    for {
        fmt.Println("Infinite loop")
    }
    
  4. For-range:

    • Iterate over collections:
    nums := []int{1, 2, 3}
    for i, v := range nums {
        fmt.Println(i, v)
    }
    

    If you don’t need the key/index in, the underscore (_) can be used, telling go to ignore the value.

    evenVals := []int{2, 4, 6, 8, 10, 12}
    for _, v := range evenVals {
        fmt.Printf("%d ", v)
    }
    

    If you just need the key/index, you can leave off the second variable:

    uniqueNames := map[string]bool{"Fred": true, "Raul": true, "Wilma": true}
    for k := range uniqueNames {
        fmt.Printf("%s ", k)
    }
    

switch

words := []string{"a", "cow", "smile", "gopher",
    "octopus", "anthropologist"}
for _, word := range words {
    switch size := len(word); size {
    case 1, 2, 3, 4:
        fmt.Println(word, "is a short word!")
    case 5:
        wordLen := len(word)
        fmt.Println(word, "is exactly the right length:", wordLen)
    case 6, 7, 8, 9:
    default:
        fmt.Println(word, "is a long word!")
    }
}

Functions

Variadic Parameters

Variadic functions accept a variable number of arguments as a slice:

func addTo(base int, vals ...int) []int {
    out := make([]int, 0, len(vals))
    for _, v := range vals {
        out = append(out, base+v)
    }
    return out
}

Multiple Return Values

Functions can return multiple values, often used with errors:

func divAndRemainder(num, denom int) (int, int, error) {
    if denom == 0 {
        return 0, 0, errors.New("cannot divide by zero")
    }
    return num / denom, num % denom, nil
}

named return values

Functions can have names specified for the return values, which predeclares variables that are used within the function to hold the return values. These values are initialized to their zero values allowing you to return them before any explicit use or assignment.

func divAndRemainder(num, denom int) (result int, remainder int, err error) {
    if denom == 0 {
        err = errors.New("cannot divide by zero")
        return result, remainder, err
    }
    result, remainder = num / denom, num % denom
    return result, remainder, err
}

Advanced Concepts

Closures

These are functions defined within a function, and are able to access and modify variables declared in the outer function. Closures allow you to pass fome function state to another function. These can also be returned from a function.

package main

import "fmt"

func main() {
    a := 20
    f := func() {
        fmt.Println(a)
        a = 30
    }
    f()
    fmt.Println(a)
}

Anonymous function f can read and write a even though a is not passed in to the function. Using := instead of = inside the closure creates a new a that ceases to exist when the closure exits.

Why use closures?

  • limit function scope

    If a function is going to be called from only one other function, but it’s called multiple times, you can use an inner function to “hide” the called function. This reduces the number of declarations at a package level, making it easier to find an unused name.

  • removing logic repetetion

    Given a piece of logic that is repeated multiple times within a function, a closure can be used to remove that repetition. See the following example from a jonbodner/my_lisp:

    package scanner
    
    import "github.com/jonbodner/my_lisp/types"
    
    func Scan(s string) ([]types.Token, int) {
        var out []types.Token
        var curTokenTxt []rune
        buildCurToken := func() {
            if len(curTokenTxt) > 0 {
                if len(curTokenTxt) == 1 && curTokenTxt[0] == '.' {
                    out = append(out, types.DOT)
                } else {
                    out = append(out, types.NAME(curTokenTxt))
                }
                curTokenTxt = make([]rune, 0)
            }
        }
        update := func(t types.Token) {
            buildCurToken()
            out = append(out, t)
        }
    
        depth := 0
        for _, c := range s {
            switch c {
            case '(':
                update(types.LPAREN)
                depth++
            case ')':
                update(types.RPAREN)
                depth--
            case '.':
                curTokenTxt = append(curTokenTxt, c)
            case '\n', '\r', '\t', ' ':
                buildCurToken()
            case '\'':
                update(types.QUOTE)
            default:
                curTokenTxt = append(curTokenTxt, c)
            }
        }
        buildCurToken()
        return out, depth
    }
    

    Here the scanner uses two closures: buildCurToken and update to make the code shorter and easier1 to understand.

  • passing to funcs/returning from funcs

    These become really interesting, allowing you to take variables within your function and use those variables outside of the function

Passing and returning funcs from funcs

Passing

Functions are values and the type of a function is specified using its parameter and return types, thus you can pass functions as parameters into functions.

NOTE: Think about the implications of creating a closure that references local variables and then passing that closure to another function. It means a closure can be used to pass some function state to another function.

An example of this pattern is when sorting slices with the stdlib sort.Slice, which takes in any slice and a function that is used to sort the slice that’s passed in. See the following with a simple type, Person, that is sorted first by last name and then by age:

package main

import (
    "fmt"
    "sort"
)

func main() {
    type Person struct {
        FirstName string
        LastName  string
        Age       int
    }

    people := []Person{			//
        {"Pat", "Patterson", 37},
        {"Tracy", "Bobdaughter", 23},
        {"Fred", "Fredson", 18},
    }
    fmt.Println(people)

    // sort by last name
    sort.Slice(people, func(i int, j int) bool { //
        return people[i].LastName < people[j].LastName
    })
    fmt.Println(people)

    // sort by age
    sort.Slice(people, func(i int, j int) bool { //
        return people[i].Age < people[j].Age
    })
    fmt.Println(people)
}
  • First a slice, people, of type Person is created.
  • people is then sorted by last name and the results printed
    • The closure passed has two parameters i and j, but within the closure people is used, so you can sort it by the LastName field. In CS terms, people is captured by the closure.
  • Another closure is defined to sort by Age following the same practice as before.
  • The original people slice is changed by the call to sort.Slice

Returning

A closure can also be returned from a function. See the following which returns a mutliplier function.

package main

import "fmt"

func makeMult(base int) func(int) int {
    return func(factor int) int {
        return base * factor
    }
}

func main() {
    twoBase := makeMult(2)
    threeBase := makeMult(3)
    for i := 0; i < 3; i++ {
        fmt.Println(twoBase(i), threeBase(i))
    }
}

Returning closures is used for a variaty cases where closures are useful. Such as sorting and and searching slices. For returning closures useful examples include middleware implementations and resource cleanup with defer.

NOTE: Functional languages such as haskell use the terminology higher-order functions to refer to a function that has a function for an input parameter or return value.

defer

Quick notes:

  • can use function, method, or closure
  • can defer multiple functions within a function
    • Runs in LIFO order; the last defer registered runs first.
  • code within defer functions runs after the return statement.
    • input parameters supplied to defer are evaluated immediatly and their values are stored until the function runs.
      package main
      
      import "fmt"
      
      func deferExample() int {
          a := 10
          defer func(val int) {
              fmt.Println("first:", val)
          }(a)
      
          a = 20
          defer func(val int) {
              fmt.Println("second:", val)
          }(a)
      
          a = 30
          fmt.Println("exiting:", a)
          return a
      }
      
      
      func main() {
          _ = deferExample()
      }
      
      

Cleanup code is attached to a the function with the defer keyword. See the following simple version of cat.

package main
import (
    "io"
    "log"
    "os"
)

func main() {
    if len(os.Args) < 2 {
        log.Fatal("no file specified")
    }
    f, err := os.Open(os.Args[1])
    if err != nil {
        log.Fatal(err)
    }
    defer f.Close() //

    data := make([]byte, 2048)

    for {
        count, err := f.Read(data)
        os.Stdout.Write(data[:count])
        if err != nil {
            if err != io.EOF {
                log.Fatal(err)
            }
            break
        }
    }
}

The important thing to see in this example is using defer to close the file handle. Once there is a valid file handle, it needs to be closed after it is used, no matter how the function is exited. defer ensurs that the cleanup code runs. It is used with the defer keyword, followed by a function or method call. Normally a function call will run immediatly but defer delays invocation until the surrounding function exits.

  • Common pattern to modify values in surrounding function

    Deferred functions can examine or modify the return values of the surrounding functio. By using named return values, it can allow your code to take actions based on an error. See the following database example:

    func DoSomeInserts(ctx context.Context, db *sql.DB, value1, value2 string) (err error) {
        tx, err := db.BeginTx(ctx, nil)
        if err != nil {
            return err
        }
        defer func() { //
            if err == nil {
                err = tx.Commit()
            }
            if err != nil {
                tx.Rollback()
            }
        }() // compile time error to leave out the braces
    
        _, err = tx.ExecContex(ctx, "INSERT INTO FOO (val) values $1", value1)
        if err != nil {
            return err
        }
        // use tx to do more database inserts here
        return nil
    }
    

    If any of the transactions fail you want to rollback the modifications, and if any succed you want to commit the changes. The closure with defer checks if err has been defined a value. If it hasn’t you run tx.Commit, which could al return an err. If it does the value err is modified. If any database interaction returned tx.Rollback is called.

  • Common pattern for resource allocation

    Take the cat program. It can be rewritten to use a helper function that opens a file and returns a closure.

    func getFile(name string) (*os.File, func(), error) {
        file, err := os.Open(name)
    
        if err != nil {
            return nil, nil, err
        }
        return file, func() {
            file.Close()
        }, nil
    }
    

    The helper returns a file, function, and an error. From main we can use the helper like so:

    f, closer, err := getFile(os.Args[1])
    if err != nil {
        log.Fatal(err)
    }
    defer closer()
    

    Since go doesn’t allow unused variables, returning the closer from the function means that the program will not compile if the function is not called, reminding the user to use defer.

Pointers

Standard address (&) and indirection/dereferencing (*) operators. Pointer tricks such as pointer arithmetic that you can do in C are not allowed in go.

Before dereferencing a pointer, you must make sure it is non-nil, otherwise the program will panic if you attempt to dereference a nil pointer:

var * int
fmt.Println(x == nil)			// prints true
fmt.Println(*x)					// panics

The pointer type can be based on any type, and the built in function new creates a pointer variable which returns a pointer to a zero-value instance of the type.

var x = new(int)
fmt.Println(x == nil)			// prints false
fmt.Println(*x)					// prints 0

However, the new function is rarely used.

For structs use & before a struct literal to create a pointer instance. When you need a pointer to a primitive type, declare a variable and then point to it.

pointers of constants / literals

Constant literals cannot use & because they don’t have a memory address, e.g.

type person struct {
    FirstName string
    MiddleName *string
    LastName string
}

p := person {
        FirstName: "Pat",
        MiddleName: "Perry", // This line will not compile
        LastName: "Peterson",
    }

There are two ways around the problem. The first is to introduce a variable to hold the constant. The second is to write a generic helper function that takes a parameter of any type and returns a pointer to that type:

func makePointer[T any](t T) *T {
  return &t
}

which allows one to write the previous code as

p := person {
        FirstName: "Pat",
        MiddleName: makePointer("Perry"),
        LastName: "Peterson",
    }

NOTE: The only time you should use pointer parameters to modify a variable is when the function expects an interface, e.g. JSON.

Difference between maps and slices

Within the go runtime, a map is implemented as a pointer to a struct, so passing a map to a function means that you are copying a pointer. Thus changes made to a map within the functions are reflected in the original.

NOTE: maps for input parameters or return values should be carefully considered. On an API design level, they are a bad choice because they say nothing about the values contained within. In general one should prefer a struct for these cases.

A slice is more complicated. Any modification to a slices contents within a function is reflected in the original, but using append to change the length of the array is not, even if the slice has a capacity greater than its length. This is due to slice being implemented a struct with three fields:

  • int for length
  • int for capacity
  • a pointer to a block of memory

Reference


  1. Does it make it easier to track the logic? I guess it depends on experience. Should try to find a writeup discussing this. ↩︎


No notes link to this note