Manche Funktionen sollen mit einem Argument auskommen, manche mit drei — und manche mit so vielen, wie der Aufrufer gerade in der Hand hat. Für diesen Fall kennt Go Variadic Functions: Funktionen, deren letzter Parameter mit ...T gekennzeichnet ist und beliebig viele Argumente des Typs T einsammelt. Du kennst das aus der Stdlib, ohne es vielleicht bewusst zu sehen: fmt.Println("a", "b", "c"), append(s, 1, 2, 3), max(7, 4, 9) — alle drei sind variadic. Im Inneren der Funktion landet alles in einem ganz normalen []T-Slice, mit len, range, Index-Zugriff. Dazu kommt der Spread-Operator slice..., der einen existierenden Slice 1:1 als variadic-Argumente weitergibt — kompakt, aber mit einer Aliasing-Falle. Dieser Artikel arbeitet die Syntax sauber durch, zeigt die Aufruf-Formen, das Zero-Argument-Verhalten, die Interaktion mit any, die Performance-Implikationen und die Patterns, in denen variadic-Funktionen wirklich glänzen.
Die Syntax — ...T am letzten Parameter
Die Go-Spec definiert die Parameter-Deklaration sehr knapp — variadic ist nur ein optionales ... vor dem Typ des letzten Parameters:
ParameterList = ParameterDecl { "," ParameterDecl } .
ParameterDecl = [ IdentifierList ] [ "..." ] Type .Aus der Spec wörtlich: „The final incoming parameter in a function signature may have a type prefixed with .... A function with such a parameter is called variadic and may be invoked with zero or more arguments for that parameter."
Drei harte Regeln folgen daraus:
- Genau ein Variadic-Parameter pro Funktion — nicht zwei.
- Der Variadic-Parameter ist immer der letzte in der Liste.
- Vor dem Variadic-Parameter dürfen beliebig viele reguläre Parameter stehen.
package main
// Nur ein Variadic-Parameter
func sum(nums ...int) int { return 0 }
// Reguläre Parameter zuerst, Variadic am Ende
func printf(format string, args ...any) {}
// Mehrere Fixed-Parameter, dann Variadic
func logAt(level int, tag string, parts ...string) {}
// FEHLER: Variadic darf nicht in der Mitte stehen
// func wrong(parts ...string, suffix string) {}
// FEHLER: nur ein Variadic-Parameter erlaubt
// func wrong2(a ...int, b ...string) {}Im Inneren der Funktion ist nums ein ganz normales Slice vom Typ []int. Das ist der gedankliche Kern: ...T am Parameter ist nur Syntax-Zucker, das was ankommt ist immer ein []T.
Klassiker aus der Stdlib
Bevor wir tiefer gehen, ein Blick auf die Funktionen, denen du täglich begegnest — alle sind variadic:
package main
import (
"fmt"
"strings"
)
func main() {
// fmt.Println(a ...any) — beliebig viele Argumente jeden Typs
fmt.Println("Wert:", 42, true, 3.14)
// append(s []T, vs ...T) []T — Slice um beliebig viele Werte erweitern
s := []int{1, 2}
s = append(s, 3, 4, 5)
fmt.Println(s)
// strings.Join(elems []string, sep string) — hier KEIN Variadic
// (zur Abgrenzung: hier wird ein bereits existierender Slice akzeptiert)
fmt.Println(strings.Join([]string{"a", "b", "c"}, "-"))
// max / min (Go 1.21+) — generisch und variadic
fmt.Println(max(7, 4, 9, 2))
fmt.Println(min(7, 4, 9, 2))
}Wert: 42 true 3.14
[1 2 3 4 5]
a-b-c
7
2Wichtig ist die Abgrenzung im Beispiel: strings.Join nimmt einen fertigen []string als regulären Parameter — das ist kein Variadic. fmt.Println und append dagegen sind variadic — der Aufrufer schreibt die Werte einzeln, nicht als Slice. Beide Welten existieren parallel, und welche du in deinem Code wählst, hängt davon ab, ob die Aufrufer typischerweise einen Slice in der Hand haben oder einzelne Werte.
Im Inneren ist es ein Slice
Sobald die Funktion betreten wird, ist der Variadic-Parameter ein ganz normaler Slice. len, range, Index — alles funktioniert wie gewohnt:
package main
import "fmt"
func describe(nums ...int) {
// nums ist []int
fmt.Printf("Typ: %T, Länge: %d\n", nums, len(nums))
// range funktioniert
for i, n := range nums {
fmt.Printf(" [%d] = %d\n", i, n)
}
// Index-Zugriff
if len(nums) > 0 {
fmt.Println("erstes Element:", nums[0])
}
}
func main() {
describe(10, 20, 30)
fmt.Println("---")
describe(7)
}Typ: []int, Länge: 3
[0] = 10
[1] = 20
[2] = 30
erstes Element: 10
---
Typ: []int, Länge: 1
[0] = 7
erstes Element: 7Konsequenz: Alles, was du mit einem []T machen kannst, kannst du mit dem Variadic-Parameter machen. Sortieren, filtern, kopieren, an andere Funktionen weiterreichen — alles legal. Es ist kein magischer Tupel, sondern Standard-Go.
Zero-Argument-Aufruf — nil-Slice, kein Panic
Eine Variadic-Funktion darf ohne ein einziges Argument für den Variadic-Teil aufgerufen werden. Was kommt dann an? Ein nil-Slice — len == 0, aber identisch mit nil:
package main
import "fmt"
func inspect(nums ...int) {
fmt.Printf("len=%d nil? %v slice=%v\n",
len(nums), nums == nil, nums)
}
func main() {
inspect() // gar kein Argument
inspect(1) // ein Argument
inspect(1, 2, 3) // drei Argumente
inspect([]int{}...) // Spread eines leeren (aber nicht-nil) Slice
inspect([]int(nil)...) // Spread eines nil-Slice
}len=0 nil? true slice=[]
len=1 nil? false slice=[1]
len=3 nil? false slice=[1 2 3]
len=0 nil? false slice=[]
len=0 nil? true slice=[]Drei Beobachtungen, die hier zusammenkommen:
- Beim direkten Zero-Argument-Aufruf (
inspect()) istnums == nil. Es gibt keinen allokierten Slice — der Compiler übergibt schlicht die Nil-Slice-Repräsentation. - Beim Spread eines leeren, aber nicht-nil Slice (
[]int{}...) istnumsebenfalls leer, aber nicht nil. Der Aufrufer entscheidet, was reicht. len(nums) == 0ist der robuste Check — er deckt beide Fälle ab. Wernums == nilschreibt, schließt die zweite Variante aus.
Der Unterschied ist meistens irrelevant, schlägt aber bei encoding/json zu: ein nil-Slice wird zu null, ein leerer Slice zu []. Wer das durchreicht, muss aufpassen.
Die zwei Aufruf-Formen
Eine Variadic-Funktion kann auf zwei Wegen aufgerufen werden. Beide sind erlaubt, aber sie tun nicht dasselbe — und du darfst sie nicht mischen:
package main
import "fmt"
func sum(nums ...int) int {
total := 0
for _, n := range nums {
total += n
}
return total
}
func main() {
// (A) Einzelne Argumente — der Compiler packt sie in einen neuen []int
fmt.Println(sum(1, 2, 3))
// (B) Spread — bestehenden Slice direkt weiterreichen
values := []int{4, 5, 6, 7}
fmt.Println(sum(values...))
// Auch zulässig: gar kein Argument
fmt.Println(sum())
}6
22
0Form (A) — einzelne Argumente — ist das, was man im Alltag schreibt. Der Compiler legt für jeden Aufruf einen frischen []int an und steckt die Argumente hinein.
Form (B) — der Spread values... — sagt dem Compiler: „Ich habe schon einen []int, nimm den unverändert." Das ist effizient (keine Kopie der Elemente), hat aber eine wichtige Folge, die der nächste Abschnitt aufrollt.
Der Spread-Operator — und seine Aliasing-Falle
Wenn du slice... an eine Variadic-Funktion übergibst, ist der Parameter im Inneren der Funktion derselbe Slice wie außerhalb — gleiches Underlying-Array, gleiches len, gleiches cap. Es wird nicht kopiert. Das ist effizient, kann aber zur Überraschung führen:
package main
import "fmt"
func zeroAll(nums ...int) {
// nums teilt das Underlying-Array mit dem Aufrufer
for i := range nums {
nums[i] = 0
}
}
func main() {
data := []int{10, 20, 30}
fmt.Println("vor zeroAll:", data)
zeroAll(data...)
fmt.Println("nach zeroAll:", data)
// Achtung: data wurde im Aufrufer verändert!
// Ohne Spread — der Compiler legt einen FRISCHEN Slice an
fmt.Println("---")
data2 := []int{10, 20, 30}
zeroAll(data2[0], data2[1], data2[2])
fmt.Println("nach zeroAll (ohne Spread):", data2)
}vor zeroAll: [10 20 30]
nach zeroAll: [0 0 0]
---
nach zeroAll (ohne Spread): [10 20 30]Das ist die zentrale Eigenheit: Spread aliasiert. Die Funktion sieht nicht nur die Werte, sondern hält denselben Slice-Header in der Hand. Wer den Slice verändert (in-place schreibt), trifft den Aufrufer.
In der Praxis ist das fast nie ein Problem — die meisten Variadic-Funktionen lesen nur. Aber sobald eine Funktion schreibt (sortiert, mutiert, vertauscht), musst du dir der Aliasing-Eigenschaft bewusst sein. Wenn die Funktion den Slice ohnehin mutieren darf, ist es performant. Wenn nicht, kopiere vor dem Aufruf:
// Defensiv kopieren, wenn der Aufrufer den Slice unverändert behalten will
snapshot := make([]int, len(data))
copy(snapshot, data)
zeroAll(snapshot...)
// data ist hier unverändertMischen verboten — Spread und Einzelargumente
Sobald du den Spread slice... benutzt, ist er das einzige Argument für den Variadic-Teil. Du kannst nicht zusätzlich einzelne Werte davor- oder dahinterstellen:
package main
func sum(nums ...int) int { return 0 }
func main() {
tail := []int{2, 3, 4}
// FEHLER: einzelne Werte + Spread sind nicht kombinierbar
// sum(1, tail...)
// KORREKT: vorher zusammenbauen
all := append([]int{1}, tail...)
sum(all...)
// Oder einzeln, ohne Spread
sum(1, 2, 3, 4)
}Der Compiler-Fehler lautet „too many arguments in call" — vielleicht ist das nicht die offensichtlichste Meldung, aber er meint: Wenn tail... da steht, ist das schon das ganze Argument. Die 1 davor ist überzählig.
Das gilt natürlich nur für den variadic-Teil. Reguläre Parameter davor sind erlaubt und üblich:
func logf(level int, format string, args ...any) {}
func main() {
params := []any{"alice", 42}
// OK: zwei reguläre Argumente, dann der Spread für args
logf(1, "user=%s age=%d", params...)
}...any — die Variadic mit Universal-Typ
Eine der häufigsten Variadic-Signaturen in der Stdlib ist ...any (vor Go 1.18: ...interface{} — exakt dasselbe, nur als Alias kürzer). fmt.Println, fmt.Printf-Argumente, log.Print — alle nehmen ...any und akzeptieren damit Werte beliebigen Typs:
package main
import "fmt"
func dump(label string, vs ...any) {
fmt.Printf("%s: %d Werte\n", label, len(vs))
for i, v := range vs {
fmt.Printf(" [%d] %T = %v\n", i, v, v)
}
}
func main() {
dump("mix", 42, "hallo", 3.14, true, []int{1, 2})
}mix: 5 Werte
[0] int = 42
[1] string = hallo
[2] float64 = 3.14
[3] bool = true
[4] []int = [1 2]Innerhalb der Funktion ist vs ein []any — jeder Wert ist in ein Interface verpackt, mit dem dynamischen Typ als Information. Das Auspacken passiert per Type Assertion oder Type Switch:
package main
import "fmt"
func classify(vs ...any) {
for _, v := range vs {
switch x := v.(type) {
case int:
fmt.Println("int:", x*2)
case string:
fmt.Println("string:", len(x), "Zeichen")
case bool:
fmt.Println("bool:", !x)
default:
fmt.Printf("anderer Typ %T\n", x)
}
}
}
func main() {
classify(7, "hallo", true, 3.14)
}int: 14
string: 5 Zeichen
bool: false
anderer Typ float64Eine subtile Falle bei ...any: Wenn du einen []string per Spread an eine ...any-Funktion übergeben willst, funktioniert das nicht direkt. Der Spread-Operator ist typ-strikt — er erwartet einen Slice des exakten Element-Typs des Variadic-Parameters:
package main
import "fmt"
func main() {
names := []string{"alice", "bob"}
// FEHLER: cannot use names (variable of type []string) as []any value
// fmt.Println(names...)
// KORREKT: explizit in []any umpacken
args := make([]any, len(names))
for i, n := range names {
args[i] = n
}
fmt.Println(args...)
// Output: alice bob
// Alternative: einzelne Werte (kein Spread)
fmt.Println(names[0], names[1])
}[]string und []any sind in Go unterschiedliche Typen — auch wenn jedes String ein any ist. Die Konvertierung muss du Element für Element machen. Genau das ist einer der Gründe, warum Generics (siehe Abschnitt 11) bei vielen Variadic-Funktionen die bessere Wahl sind.
printf vs. println — die %v-vs-Spread-Falle
Eine spezifische Quelle für Verwirrung bei fmt: Wie übergibst du einen Slice an einen Format-String mit %v? Hier liefern Spread und Nicht-Spread völlig unterschiedliche Ergebnisse:
package main
import "fmt"
func main() {
nums := []int{1, 2, 3}
// (1) Slice als EIN Wert übergeben — %v formatiert den Slice
fmt.Printf("ohne Spread: %v\n", nums)
// (2) Slice spreaden — die Elemente werden einzeln zu Argumenten
fmt.Printf("mit Spread: %v %v %v\n", nums...)
// (3) FALSCH: ein Format-Verb, drei Argumente
// fmt: %!(EXTRA int=2, int=3) Hinweis
fmt.Printf("nur ein %%v: %v\n", nums...)
}ohne Spread: [1 2 3]
mit Spread: 1 2 3
nur ein %v: 1%!(EXTRA int=2, int=3)Merkregel: %v druckt ein Argument. Wer einen Slice komplett darstellen will, übergibt den Slice — ohne Spread. Wer die Elemente einzeln in einen Format-String einsetzen will, spreadt und sorgt für die passende Anzahl Format-Verben.
Performance — die unsichtbare Allokation
Jeder Aufruf einer Variadic-Funktion mit einzelnen Argumenten erzeugt logisch einen neuen Slice. In der Praxis erkennt der Go-Compiler oft, dass dieser Slice nicht aus der Funktion entkommt, und legt ihn auf dem Stack ab (Escape-Analyse) — dann ist die Allokation gratis. Wenn der Slice aber escaped (etwa weil die Funktion ihn in einem Goroutine-Closure oder einer globalen Datenstruktur speichert), passiert eine echte Heap-Allokation:
package main
import "fmt"
// Stack-OK: nums wird nur gelesen, escapet nicht
func sumS(nums ...int) int {
total := 0
for _, n := range nums {
total += n
}
return total
}
// Escape: nums verlässt die Funktion via Closure
var stash func() []int
func capture(nums ...int) {
stash = func() []int { return nums }
}
func main() {
// Im Hot-Path-Code lohnt sich ein Blick auf:
// go build -gcflags="-m" ./...
fmt.Println(sumS(1, 2, 3))
capture(10, 20)
fmt.Println(stash())
}Was du im normalen Anwendungscode mitnehmen solltest:
- Für 99 % der Aufrufe ist die Variadic-Form frei. Der Compiler räumt auf, der Profiler zeigt nichts.
- In Hot Paths (tight Loops, Hochfrequenz-Logging) kann die Allokation messbar werden — dann lohnt sich entweder eine
func(s []T)-Variante daneben oder die direkte Übergabe eines vorbereiteten Slice per Spread. - Beim Spread (
slice...) entsteht keine zusätzliche Allokation — der bestehende Slice wird weitergereicht. - Verifiziere im Zweifel mit
go build -gcflags="-m" ./...(zeigt Escape-Entscheidungen) undgo test -bench=. -benchmem.
Praxis-Patterns
Variadic-Funktionen erscheinen vor allem in drei wiederkehrenden Mustern.
Option-Pattern. Statt einer Funktion mit zehn Parametern bietest du eine Variadic-Liste von Option-Werten an. Der Aufrufer setzt nur, was er ändern will:
package main
import "fmt"
type Server struct {
Host string
Port int
TLS bool
Timeout int
}
type Option func(*Server)
func WithPort(p int) Option { return func(s *Server) { s.Port = p } }
func WithTLS(tls bool) Option { return func(s *Server) { s.TLS = tls } }
func WithTimeout(sec int) Option { return func(s *Server) { s.Timeout = sec } }
// Variadic: beliebig viele Optionen, in beliebiger Reihenfolge
func NewServer(host string, opts ...Option) *Server {
s := &Server{Host: host, Port: 8080, Timeout: 30}
for _, opt := range opts {
opt(s)
}
return s
}
func main() {
s := NewServer("localhost", WithPort(9000), WithTLS(true))
fmt.Printf("%+v\n", s)
}&{Host:localhost Port:9000 TLS:true Timeout:30}Logging-Wrapper. Einen Logger in den Lärm der App hineinreichen — und mit Variadic die args... direkt durchschleifen:
package main
import "fmt"
func Info(format string, args ...any) {
fmt.Printf("[INFO] "+format+"\n", args...)
}
func Warn(format string, args ...any) {
fmt.Printf("[WARN] "+format+"\n", args...)
}
func main() {
Info("user %q logged in (age=%d)", "alice", 30)
Warn("disk usage at %d%%", 92)
}[INFO] user "alice" logged in (age=30)
[WARN] disk usage at 92%Die Stelle args... ist der typische Durchschleifer: Der Wrapper nimmt sein eigenes ...any und gibt es per Spread an fmt.Printf weiter — kein Auspacken, keine Kopie.
Aggregat-Funktionen. Klassisch sum, max, min, concat — alle profitieren von der einfachen Variadic-Syntax:
package main
import "fmt"
func concat(parts ...string) string {
total := 0
for _, p := range parts {
total += len(p)
}
buf := make([]byte, 0, total)
for _, p := range parts {
buf = append(buf, p...)
}
return string(buf)
}
func main() {
fmt.Println(concat("Hallo", " ", "Welt", "!"))
}Hallo Welt!Generics + Variadic — typsicher seit Go 1.18
Seit Go 1.18 lassen sich Variadic-Parameter mit Type-Parameters kombinieren. Das löst den any-Typverlust und gibt dem Aufrufer echten Compiler-Schutz:
package main
import (
"cmp"
"fmt"
)
// T muss vergleichbar (Ordered) sein — int, float, string, ...
func Min[T cmp.Ordered](first T, rest ...T) T {
m := first
for _, v := range rest {
if v < m {
m = v
}
}
return m
}
func main() {
fmt.Println(Min(7, 4, 9, 2)) // int
fmt.Println(Min("banana", "apfel", "c")) // string
fmt.Println(Min(3.14, 2.71, 1.41)) // float64
}2
apfel
1.41Der Trick first T, rest ...T: Mindestens ein Wert ist Pflicht (sonst hätte Min() keinen vernünftigen Rückgabewert), weitere sind variadic. Genau diese Signatur nutzen die eingebauten max und min seit Go 1.21.
Wenn du Min[int](nums...) mit einem []int aufrufen willst: Der Spread funktioniert für den variadic-Teil — der erste Pflicht-Parameter muss aber separat herausgezogen werden, was an der Aufruf-Stelle etwas hässlich wird. In der Praxis ist die func Min[T cmp.Ordered](vs ...T) T-Variante ohne Pflicht-Parameter oft pragmatischer; sie muss dann len(vs) == 0 selbst abfangen.
Häufige Stolperfallen
Spread teilt das Underlying-Array — Mutation im Callee trifft den Caller.
f(slice...) reicht den Slice-Header 1:1 weiter — keine Kopie, gleiches Backing-Array. Wer in der Funktion in den Slice schreibt (nums[i] = 0, sort.Ints(nums)), verändert die Daten beim Aufrufer. Lese-only-Variadics sind safe, schreibende brauchen eine defensive copy davor.
Mit Spread können keine zusätzlichen Variadic-Argumente kommen.
sum(1, tail...) ist Compile-Fehler. Sobald tail... als variadic-Argument auftaucht, ist es das Argument — keine Mischformen erlaubt. Lösung: vorher append([]int{1}, tail...) und das Ergebnis spreaden, oder ganz auf den Spread verzichten.
Nur ein Variadic-Parameter, immer am Ende.
func f(a ...int, b ...string) ist Syntax-Fehler. func f(parts ...string, suffix string) ebenso. Wer mehrere variable Listen braucht, nimmt zwei reguläre Slice-Parameter (a []int, b []string) — dann liegt der Aufruf eben in f([]int{1, 2}, []string{"x"}).
Zero-Argument-Aufruf liefert nil-Slice (nicht leeren Slice).
f() ohne Argumente: nums == nil ist true, len(nums) == 0. Beim Spread eines leeren Slice (f([]int{}...)) dagegen ist nums nicht-nil. Bei encoding/json wird das sichtbar: nil-Slice serialisiert zu null, leerer Slice zu []. Für robuste Checks len(nums) == 0 schreiben, nicht nums == nil.
fmt.Printf("%v", slice) ist nicht dasselbe wie fmt.Printf("%v", slice...).
Ohne Spread: ein Format-Verb, ein Argument (der ganze Slice — Output: [1 2 3]). Mit Spread: ein Format-Verb pro Element. Wer die Anzahl der %vs nicht anpasst, kriegt %!(EXTRA ...)-Hinweise. Bei Logging-Wrappers ist format+"\n", args... der korrekte Durchschleifer.
append(s, t...) ist ein Variadic-Aufruf, kein Sprach-Operator.
append ist eine ganz normale variadic-Funktion mit Signatur append(slice []T, elems ...T) []T. Der Spread t... greift hier die Element-Variante — ohne ihn würdest du den Slice t als ein Element anhängen wollen, was Typ-Fehler ergibt. Verstehen, warum der Spread da steht, hilft beim Lesen der Stdlib.
Variadic auf Methoden mit Pointer-Receiver ist erlaubt.
Es gibt keinen Unterschied zwischen Funktion und Methode in Sachen Variadic. func (s *Server) Log(args ...any) ist legal und üblich. Auch hier gilt: nur ein Variadic-Parameter, immer am Ende der Methodensignatur.
Jede Aufruf-Stelle kann eine Allokation kosten.
Bei einzelnen Argumenten legt der Compiler logisch einen []T an. Escape-Analyse räumt das meistens auf den Stack — aber in Hot Paths kann es zählen. go build -gcflags="-m" zeigt es; -benchmem misst es. Der Spread slice... ist allokationsfrei (gleicher Slice-Header).
...any verliert die Typ-Information — bei vielen Args sind Generics besser.
func f(vs ...any) verpackt jedes Element in ein Interface. Das kostet ein Boxing pro Wert (mehr Allokationen, langsamerer Zugriff) und der Compiler kann am Aufruf-Ort nicht prüfen, ob die Typen passen. Wenn alle Argumente denselben Typ haben sollen, ist func f[T any](vs ...T) typsicher und meist schneller. fmt-Familie bleibt aus Kompatibilitätsgründen bei ...any.
Weiterführende Ressourcen
Externe Quellen
- Function types — Go Language Specification (Variadic Parameter)
- Passing arguments to ... parameters — Go Specification
- Calls — Go Specification
- Effective Go: Variadic functions / append
go vetprintf check (Format-String vs. Argumente)
Verwandte Artikel
- Multiple Returns — das
(wert, err)-Pattern und Multi-Return-Mechaniken - Closures — Funktionen, die ihren Scope einfangen
- First-Class-Funktionen — Funktionen als Werte
- Rekursion — Funktionen, die sich selbst aufrufen
- Funktionen — Übersicht über alle Funktions-Mechaniken
- Slice — Datentyp, len/cap, Underlying-Array