Lesson 20 of 25 · Larger programs

Values, references and memory

Salam runs without a garbage collector. Nothing is freed behind your back, and nothing is shared unless you can see why. This lesson shows when a value is copied, when it is shared, and who is responsible for giving memory back.

Structs are copied

When you put a struct into another variable, or pass it to a function, a copy is made, and changing the copy leaves the original alone. If a function has to change the caller's value, declare the parameter as a reference with &:. A reference also saves copying a large struct; the functions lesson has more on it.

mem-copy.salam
struct Account:
    pub balance: int = 0
end

func deposit(a &: Account, amount: int):
    a.balance += amount
end

func main:
    mut first := Account { balance = 100 }
    mut second := first
    second.balance = 500
    println first.balance, second.balance
    deposit(first, 25)
    println first.balance
end
Output100 500 125

Arrays are copied, slices are views

A fixed array such as int[3] is a value too: assigning it or passing it as int[3] copies every element. A slice parameter, int[], is a view of the caller's array, so writing through it changes the original. Choose the parameter type by what the function is allowed to do.

mem-array.salam
func zero_first(scores: int[3]):
    scores[0] = 0
end

func zero_first_in_place(scores: int[]):
    scores[0] = 0
end

func main:
    week := [7, 5, 9]
    mut plan := week
    plan[1] = 100
    println week, plan
    zero_first(week)
    println week
    mut live := [7, 5, 9]
    zero_first_in_place(live)
    println live
end
Output[7, 5, 9] [7, 100, 9] [7, 5, 9] [0, 5, 9]

A Vector shares its storage

A Vector or a HashMap is a small header (where the items are, how many there are, how much room is left) that points at storage on the heap. Assigning one copies only the header. Both names then see the same items, so cart.set(0, "rice") shows through view, but each name keeps its own length, so the "tea" pushed through cart is not counted by view. Do not rely on that half-shared state. When you need an independent value, ask for one with copy(), and free it like any other Vector.

mem-share.salam
func main:
    cart := Vector {} as Vector<str>
    defer cart.free()
    cart.push("bread")
    cart.push("milk")
    view := cart
    cart.set(0, "rice")
    cart.push("tea")
    println cart, view

    saved := cart.copy()
    defer saved.free()
    cart.set(1, "eggs")
    saved.push("salt")
    println cart, saved
end
Output["rice", "milk", "tea"] ["rice", "milk"] ["rice", "eggs", "tea"] ["rice", "milk", "tea", "salt"]

A HashMap behaves the same way, and so does a Vector inside a struct: copying the struct copies the header of its Vector, not the items.

copy and deep_copy

copy() gives new storage only for the outer container. When the items are containers themselves, as in a Vector of rows, the copy still shares every row. deep_copy() goes all the way down and copies the rows too. Change one seat in the original below: the shallow copy sees it, the deep copy does not.

mem-deep.salam
func free_grid(g: Vector<Vector<int>>):
    each row in g:
        row.free()
    end
    g.free()
end

func main:
    seats := Vector {} as Vector<Vector<int>>
    repeat 2 in r:
        row := Vector {} as Vector<int>
        repeat 3 in c:
            row.push(r * 3 + c + 1)
        end
        seats.push(row)
    end
    shallow := seats.copy()
    deep := seats.deep_copy()
    seats.get(0).set(0, 0)
    println seats
    println shallow
    println deep
    shallow.free()
    free_grid(deep)
    free_grid(seats)
end
Output[[0, 2, 3], [4, 5, 6]] [[0, 2, 3], [4, 5, 6]] [[1, 2, 3], [4, 5, 6]]

Each copy has to be freed, and so does every container a deep copy made: that is why free_grid frees each row before the outer Vector, while the shallow copy, whose rows belong to the original, only frees its own storage. A struct gets copy() and deep_copy() too, built by the compiler from its fields.

Freeing: free and defer

Numbers, booleans, plain structs and fixed arrays need no freeing. Anything that holds heap storage does: a Vector, a HashMap, a dyn box, and the strings described in the next section. The habit worth forming is to write defer x.free() on the line right after x is created, so it is freed when the function ends, whatever path it takes.

A function that builds and returns a container hands it over: from then on the caller owns it and frees it. Freeing a container frees its own storage, not what the items own, so a map of Vectors frees each Vector first.

mem-free.salam
struct Student:
    pub name: str = ""
    pub class: str = ""
end

func by_class(all: Vector<Student>): HashMap<str, Vector<str>>:
    groups := HashMap {} as HashMap<str, Vector<str>>
    each s in all:
        names := groups.has(s.class) ? groups.get(s.class) : Vector {} as Vector<str>
        names.push(s.name)
        groups.put(s.class, names)
    end
    ret groups
end

func main:
    students := Vector {} as Vector<Student>
    defer students.free()
    students.push(Student { name = "Sara", class = "7A" })
    students.push(Student { name = "Reza", class = "7B" })
    students.push(Student { name = "Mina", class = "7A" })

    groups := by_class(students)
    defer groups.free()
    each class, names in groups:
        println class, names
    end
    each _, names in groups:
        names.free()
    end
end
Output7A ["Sara", "Mina"] 7B ["Reza"]

Look at how by_class adds a name. groups.get(c) returns a copy of the Vector's header, so pushing onto it and doing nothing else could leave the map holding the old length. Taking the Vector out, pushing, and putting it back with put keeps the map up to date.

Who frees a string

A string literal such as "hello" is part of the program and is never freed. A string that is built while the program runs lives on the heap. The rule in the standard library is simple: a function that returns a str returns a new string that the caller owns, on every path, so you free it with mem.Free(s as void*) when you are done. Follow the same rule in your own functions.

mem-str.salam
import str
import mem

func slug(title: str): str:
    ret str.Replace(title, " ", "-")
end

func tag(title: str): str:
    s := slug(title)
    defer mem.Free(s as void*)
    ret "#" + s
end

func main:
    url := slug("my first post")
    defer mem.Free(url as void*)
    println url

    t := tag("salam news")
    defer mem.Free(t as void*)
    println t

    b := str.NewBuilder()
    defer str.BufFree(b)
    repeat 3 in i:
        if i > 0:
            str.BufAppend(b, ", ")
        end
        str.BufAppend(b, "row ")
        str.BufAppendInt(b, (i + 1) as i64)
    end
    report := str.BufStr(b)
    defer mem.Free(report as void*)
    println report
end
Outputmy-first-post #salam-news row 1, row 2, row 3
  • tag frees the result of slug after using it. Writing ret "#" + slug(title) instead would leave that string with no owner, and it would never be freed.
  • Every + on strings makes a new heap string, so a chain like a + b + c leaves the middle result behind. Short-lived programs do not mind; a loop or a server that runs for days does. For text built in pieces, use a str.NewBuilder(), take the result with BufStr and release the builder with BufFree.

Pointers and mem

For raw memory and for C libraries, Salam has pointers: i64* points at an i64, void* is an untyped pointer and null is the empty one. mem.Allocate(bytes) returns a block of memory, p[i] reads and writes the i-th element, and mem.Free gives the block back. sizeof(T) tells you how many bytes one element takes.

mem-pointer.salam
import mem

func main:
    n := 5
    squares := mem.Allocate((n as u64) * sizeof(i64)) as i64*
    defer mem.Free(squares as void*)
    repeat n in i:
        squares[i] = (i * i) as i64
    end
    mut total := 0 as i64
    repeat n in i:
        total += squares[i]
    end
    println squares[4], total
    nothing := null as i64*
    println nothing == null
end
Output16 30 true

Nothing checks a pointer for you: reading past the end of the block, or using it after mem.Free, is a bug that may not show up right away. Ordinary Salam code rarely needs pointers; reach for a Vector first. The C interop lesson shows where pointers are needed.

Finding leaks with memcheck

salam memcheck app.salam builds the program with debug information and runs it under a memory checker: Valgrind when it is installed, AddressSanitizer otherwise. When the program ends it reports memory that was never freed and any read or write outside a block, each with the line of Salam code that allocated it. salam build app.salam --asan builds the same AddressSanitizer check into an ordinary executable.

The string and pointer examples above run under salam memcheck with no errors. Run it now and then on your own programs, and always on anything meant to run for a long time.

Rules of thumb

  • Structs and fixed arrays are values: assigning copies them. Use &: when a function must change the caller's value.
  • Assigning a Vector or a HashMap shares its items. Use copy() or deep_copy() when you need an independent value.
  • Whoever creates heap memory frees it, or hands it to someone who will. Write defer x.free() right after creating x.
  • A str returned by a function belongs to the caller; free it with mem.Free. Never free a literal.
  • Free what is inside a container before the container itself.
  • Check long-running programs with salam memcheck.