Skip to content
academia.sh

Lesson 08 / 18

Variadic Functions

A variadic parameter given no argument at all becomes a nil slice, arguments written one by one produce a fresh slice, and opening an existing slice with ... shares the same backing array without copying it — a change the callee makes is visible to the caller. Limiting measurement: a nil slice and an empty slice are the same length but diverge on == nil, and this difference becomes visible in their JSON counterpart.

Published

In this lesson

  • Measures what a variadic parameter carries across three separate call forms
  • Shows that opening an existing slice with ... does not copy it, and that the callee's change is visible to the caller
  • Measures that a nil slice and an empty slice are the same length but separate in identity, and where that difference becomes visible
Contents

The previous lesson measured that a return value has to be used once it is bound to a name. This lesson carries the same question to the argument side: when a function does not know in advance how many arguments it will take, what does that parameter carry when it is given no argument at all, and what is the relationship between the two when an existing slice is passed to that parameter by being opened?

The Programming Foundations course (M01/K02) built pass-by-value and pass-by-reference using Python; there, passing a list into a function meant sharing the list itself. That concept is not rebuilt here — one sentence is enough to state the difference: in Go everything is passed by value, slices included; what this lesson measures is how the difference between “pass by value” and “passing a value that points to a shared backing array” shows up in variadic arguments.

Three Call Forms

In Go, putting ... in front of a parameter makes that parameter accept a variadic number of arguments, and inside the function body it behaves like a slice. The caller can call it in three separate forms: giving no argument at all, listing arguments one by one, or opening an existing slice already sitting in a variable with .... All three are valid syntax, but they do not all produce the same thing behind the signature.

This course itself has been using variadic arguments from the very start: every fmt.Printf and fmt.Println call is a call to a standard-library function taking a variadic number of arguments. Being able to pass a single value in one call and five in another is exactly thanks to the mechanism this lesson measures. This lesson asks how that same mechanism behaves when we write our own functions with it.

Two helper functions measure this parameter’s behavior from two directions: properties returns its length and whether it is nil; zeroFirst changes its first element, letting us test whether that change is visible to the caller.

// helpers.go — two helpers for a variadic parameter
package main

func properties(numbers ...int) (length int, isNil bool) {
	return len(numbers), numbers == nil
}

func zeroFirst(numbers ...int) {
	if len(numbers) > 0 {
		numbers[0] = 0
	}
}

LB23. properties‘s return signature uses the previous lesson’s named return; numbers == nil is the standard way to test whether a slice is nil — the same comparison measured in the Zero Values lesson is run here over a parameter. zeroFirst also borrows a technique from that same lesson: the len(numbers) > 0 check exists so as not to panic by indexing into a nil slice — reaching for a nil slice’s zeroth element runs past the slice’s bounds, exactly as it would with an empty slice.

We run the three call forms side by side: with no argument at all, with arguments written one by one, and with an existing slice opened with ....

// variadic.go — no argument at all, a slice opened, an opened slice never being copied
package main

import "fmt"

func main() {
	l1, n1 := properties()
	fmt.Printf("%-36s length=%d nil=%v\n", "no argument given", l1, n1)

	l2, n2 := properties(1, 2, 3)
	fmt.Printf("%-36s length=%d nil=%v\n", "three arguments written one by one", l2, n2)

	source := []int{10, 20, 30}
	l3, n3 := properties(source...)
	fmt.Printf("%-36s length=%d nil=%v\n", "existing slice opened (...)", l3, n3)

	zeroFirst(1, 2, 3)
	fmt.Printf("%-36s %s\n", "did the one-by-one values change", "nothing observable, caller has no slice")

	zeroFirst(source...)
	fmt.Printf("%-36s source=%v\n", "did the opened slice change", source)
}
no argument given                    length=0 nil=true
three arguments written one by one   length=3 nil=false
existing slice opened (...)          length=3 nil=false
did the one-by-one values change     nothing observable, caller has no slice
did the opened slice change          source=[0 20 30]

LB24. The first three lines show what the same parameter carries across three different call forms. Given no argument (properties()), the parameter is a nil slice — length 0 and empty identity. When arguments are written one by one (properties(1, 2, 3)), the compiler builds a fresh slice to hold these three values; this slice is not nil, because something really was allocated somewhere. When an existing slice is opened with ... (properties(source...)), the length and nilness come out the same as the second line — but this line’s real difference shows up in the fourth and fifth lines.

The nil result in the first line confirms the pattern established in the first lesson: giving no argument at all is not an error, it is a defined zero value. The second line shows, for the first time, that it is not a variable but the call itself that produces a slice — writing 1, 2, 3 builds a slice at the moment of the call, even though no slice literal ([]int{1, 2, 3}) ever appears in the source.

Reading the first three lines by looking only at the length and nil columns, the second and third lines cannot be told apart: both read length=3 nil=false. This shows a limit of the measurement — the properties function itself has no way of knowing whether the slice passed to it is fresh or shared, because that information does not show up directly in any of the slice header’s fields (length, capacity, pointer). Seeing this distinction needs a different measurement, zeroFirst.

An Opened Slice Is Not Copied

This ambiguity reveals one more limit of the “defined” class. Each of the eight defined lines in an earlier lesson gave a certain answer to a specific question; here the question is different — it asks whether a function, by looking at its own parameter, can know how connected that parameter is to its caller. The answer is no: the slice header carries no such information, it can only be uncovered by observing behavior (changing it and watching the result).

The fourth and fifth lines run the same function (zeroFirst) with two different call forms. In the call zeroFirst(1, 2, 3), the slice that gets changed is a fresh one built only for that call — there is no variable on the caller’s side corresponding to this slice at all, so the question “did it change” does not even mean anything here. In the call zeroFirst(source...), the situation is different: source is [10 20 30] before the call, [0 20 30] after it. Writing numbers[0] = 0 inside the function changed the caller’s own source variable.

LB25. This shows that a slice opened with ... is not copied. Go does not allocate a new backing array for the call f(slice...); it passes slice’s own header (length, capacity, pointer to the backing array) directly into the parameter. The parameter and source share the same backing array; an element change made through the parameter becomes visible through source as well, since it reads the same backing array.

This can look like it contradicts the “pass by value” rule (built as a concept in the Programming Foundations course, not rebuilt here) — as if the slice were passing “by reference.” But what is passed is still a value: the slice header itself is copied, only the backing array that header points to is not copied. The difference between zeroFirst(1, 2, 3) and zeroFirst(source...) is not in the passing rule, it is in which backing array is shared: in the first, the parameter points to its own fresh backing array that no one else sees from anywhere else; in the second, the parameter points to source’s already existing backing array, and source keeps seeing that same backing array too.

This distinction means that arguments written one by one are always safe, while an opened slice sometimes carries a sharing risk. In a call like sum(1, 2, 3), the function changing its arguments never affects the caller, because there is no caller-side slice to share at all. In a call like sum(slice...), the function writing into its own parameter can also change slice itself. This raises a question that needs asking while writing a function: if a variadic function writes into its own parameter, that writing being able to affect the caller too should be stated in the source — the signature does not show this risk on its own, because func zeroFirst(numbers ...int) looks exactly the same for both the zeroFirst(1, 2, 3) and zeroFirst(source...) calls.

This explains why almost none of the standard library’s variadic functions write into their own parameter: fmt.Println only reads its arguments, it never changes them. If a function you are writing needs to write into its own variadic parameter, this lesson carries a warning: that write can quietly leak into a slice the caller opened with ....

Limiting Measurement: A Nil Slice and an Empty Slice Are Not the Same

In the first line we saw a nil slice, in the second and third lines slices that were not nil. But a slice that is not nil can also be empty. Comparing properties() with properties([]int{}...) makes this difference clear: the first has no argument at all, the second has one argument — that argument, built with []int{}, is a slice with zero elements that really does exist.

// limiter.go — a nil slice and an empty slice are not the same thing
package main

import (
	"encoding/json"
	"fmt"
)

func main() {
	lNil, nNil := properties()
	lEmpty, nEmpty := properties([]int{}...)

	fmt.Printf("%-33s length=%d nil=%v\n", "no argument (nil slice)", lNil, nNil)
	fmt.Printf("%-33s length=%d nil=%v\n", "empty slice opened ([]int{}...)", lEmpty, nEmpty)

	var nilSlice []int
	emptySlice := []int{}

	bNil, _ := json.Marshal(nilSlice)
	bEmpty, _ := json.Marshal(emptySlice)
	fmt.Printf("%-33s json=%s\n", "nil slice's json counterpart", bNil)
	fmt.Printf("%-33s json=%s\n", "empty slice's json counterpart", bEmpty)
}
no argument (nil slice)           length=0 nil=true
empty slice opened ([]int{}...)   length=0 nil=false
nil slice's json counterpart      json=null
empty slice's json counterpart    json=[]

The first two lines show a difference len cannot tell apart: both have a length of 0, but one is nil and the other is not. []int{}, even though it is empty, is a slice allocated somewhere; it has no elements, but it has an identity. A nil slice, on the other hand, is never allocated anywhere, and it has no identity either. len does not tell these two apart because length counts how many elements are held, not whether something was allocated.

The difference shows up in a concrete place: the encoding/json package’s converter. A nil slice is written in JSON as null; an empty but non-nil slice is written as []. Both carry the information “there are no elements at all,” yet the converter treats these two cases as separate counterparts — one meaning “there is no value,” the other “there is a value but it is empty.” This choice made by the standard library turns the difference between nil and an empty slice from a mere language rule into a result visible in a real program’s output.

This pair of counterparts also contrasts with the same two slices behaving in an entirely identical way in a different context. When walked with a for range loop, there is no difference at all between a nil slice and an empty slice — both finish the loop without ever iterating. When append is called, both again behave the same — elements can be added to either. The difference only shows up when identity is asked (== nil) or when identity-sensitive code (like encoding/json) comes into play. This shows that the defined class carries one more internal distinction of its own: some operations look at a slice’s identity, some look only at its content.

This adds one more limit to the course’s zero-value measurement. A nil slice carries a defined zero value, as measured in the Zero Values lesson, and is open to operations like append. But being nil is not the same thing as “being empty,” and this difference can surface not only in an == nil comparison but also in other code that processes the slice (here, in a converter). Whether a function knows it received no argument at all (nil) or was called with an empty slice ([]int{}...) can change the behavior of the callee.

At the end of eight lessons, we can return to the question asked at the opening: how does a language decide what stands in for the thing you did not write? This topic asked this question in six separate places: what is required for a source to compile, what a variable’s type is inferred to be, what fills a constant sequence, what part of a control structure is skipped, what happens when a return value is unwritten, and now, what happens when a parameter is given no argument at all. The answer was never uniform: sometimes the language put a defined value in place, sometimes the compiler never accepted the source at all, sometimes the result was left to runtime. The 8/4/3 split measured in the Zero Values lesson showed that none of these three answers dominates — all three are part of the language’s design.

The nil slice measured in this lesson is the most visible member of that split, because almost every operation on a slice (len, append, for range) absorbs the nil case without friction — a programmer could work with a nil slice for days and never notice. This invisibility ends, exactly as this lesson showed, at one boundary: the moment identity is asked, or identity-sensitive code comes into play, being nil suddenly becomes visible.

Throughout this topic’s eight lessons, the measurement followed the same order every time: put two cases side by side, run the difference to show it, then find where that very difference itself breaks down. This last lesson carries the same order too — three call forms were run side by side, the difference between them (sharing) was measured, and even that difference was found to have its own limit (the distinction between nil and empty). The next topic carries the same order from a single function’s parameter to an entire family of types.

Summary

  • If a variadic parameter is given no argument at all, the parameter becomes a nil slice.
  • When arguments are written one by one, the compiler builds a fresh slice; this slice is not nil.
  • When an existing slice is opened with ..., it is not copied — the parameter shares the same backing array as the caller’s slice, and the callee’s change is visible to the caller.
  • A nil slice and an empty slice are the same in terms of len (0) but diverge in terms of == nil.
  • This difference does not stay confined to a comparison: the encoding/json package writes a nil slice as null, an empty slice as [].
  • Operations like for range and append do not tell a nil slice apart from an empty one; the difference only shows up in identity-sensitive operations (== nil, encoding/json).

Next Step

A parameter given no argument at all becoming a nil slice is the most visible consequence of zero values seen in this course so far. This topic closes here; the next topic looks at the type itself — how do a slice, a map, and a struct meet the unwritten, and when a value is assigned to a variable, is what results a copy, or, as we saw in this lesson, a shared backing array?

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close