Lesson 10 / 13
Generic Functions
A generic function's type parameter is never written in the source at all, but the compiler builds it at the call site by inference: the same firstElement source, called three times with int, string, and float64 slices, gives %T three different answers. The limiting measurement shows that an unconstrained type parameter allows almost no operation in the body at all: == and + do not compile, only type-independent operations such as len pass.
Published Updated
In this lesson
- Reads, with %T, that the type parameter of the same generic function is inferred at the call site every time it is called with three separate types
- Distinguishes two cases where inference falls short (type ambiguity, a call with no arguments) by writing the type parameter explicitly
- Classifies which operations an unconstrained type parameter allows in the body by asking the compiler
Contents
The Panic and Recover lesson measured a binding built nowhere at all: the fact that a panic
exists never appears in the caller’s signature at all; the caller only learns of it while the
program is running. This topic does the reverse. A generic function’s type parameter is also
never written in the source at all — when firstElement[T any](s []T) is called, what T will
be never appears on any call line — but the binding does not stay unresolved: the compiler
builds it at the call site, by looking at the type of the arguments, and the binding it
builds never enters inside the value at all. This asks the course’s question once again:
where is this binding built?
In the common measurement, four of sixteen bindings sit in the declaration, four at the use site, eight inside the value; the type parameter inference that is this lesson’s subject is one of the four bindings built at the use site. Generics and interfaces answer the same question in two separate places — in an interface the binding enters the value, in a generic it never does — and this difference is the subject of the course’s third claim; this lesson builds the first half of that difference, the use site.
Before generics, the same job had to be written one of two ways: either a separate function for
every type (firstElementInt, firstElementString, firstElementFloat), or a single function
taking the parameter as the empty interface (any) and converting back to the concrete type in
the body with a type assertion. The first duplicates the source — four copies of the same logic,
four separate places each requiring maintenance. The second keeps the source single but pushes
type information from compile time to run time: the function accepts whatever type the caller
passes; a wrong type surfaces only at run time, when a type assertion fails. A generic function
opens a third path: the source stays single, but type checking stays at compile time —
firstElement called with a []bool is rejected before that line ever compiles; T is written
once in the body and is checked separately for every call. Where these three writings bind the
same job — by duplicating it in the declaration, by delaying it into the value, by compiling it
at the use site — is the subject of the course’s closing lesson; this lesson builds only the
third.
The Type Parameter Is Built at the Call Site
A generic function adds a type parameter list in square brackets right after its name:
func firstElement[T any](s []T) (T, bool). T here is not a concrete type, it is a
placeholder to be filled in when the function is called; any states the boundary of types this
placeholder accepts — for now there is no boundary, every type is valid. The function itself is
written in a single source, and this source is independent of which concrete type it will be
instantiated with.
GE1. The var zero T line in the function body is a zero-value production written with no
knowledge at all of what T will be; this line sits once in the source and stays exactly the
same on every call — it is a declaration. T itself changes on every call, and nowhere at
all in the source is it written where this change happens; the compiler builds it only by
looking at the type of the argument on the call line — it is a use-site binding. The same
function’s two parts fall into two separate classes of the course.
// inference.go — the type parameter is inferred at the call site, the zero-value logic sits in the declaration
package main
import "fmt"
func observe(name, location, evidence string) {
fmt.Printf("%-42s %-13s %s\n", name, location, evidence)
}
// firstElement runs from the same source for every type; the "var zero T" line sits
// in the function's own declaration and never knows which concrete type will come.
func firstElement[T any](s []T) (T, bool) {
var zero T
if len(s) == 0 {
return zero, false
}
return s[0], true
}
func zeroValue[T any]() T {
var z T
return z
}
func main() {
fmt.Println("-- where the binding is made: a generic function --")
i, _ := firstElement([]int{7, 2, 9})
observe("type parameter (int slice)", "use site", fmt.Sprintf("inferred, T=%T", i))
s, _ := firstElement([]string{"apple", "pear"})
observe("type parameter (string slice)", "use site", fmt.Sprintf("inferred, T=%T", s))
f, _ := firstElement([]float64{1.5, 2.5})
observe("type parameter (float slice)", "use site", fmt.Sprintf("inferred, T=%T", f))
observe("zero-value logic (var zero T)", "declaration", "in firstElement's own source, always the same line")
empty := zeroValue[string]()
observe("call inference cannot handle", "use site", fmt.Sprintf("zeroValue[string]() written explicitly, %T", empty))
}
-- where the binding is made: a generic function -- type parameter (int slice) use site inferred, T=int type parameter (string slice) use site inferred, T=string type parameter (float slice) use site inferred, T=float64 zero-value logic (var zero T) declaration in firstElement's own source, always the same line call inference cannot handle use site zeroValue[string]() written explicitly, string
Every call is an instantiation of the generic function: firstElement, instantiated with
T=int, corresponds to a function operating on an int slice; instantiated with T=string, to
another function operating on a string slice — both from the same source, but with a different
type bound. The word instantiation will come up twice more after this lesson: in the Type
Constraints lesson, which instantiations a constraint rejects will be measured; in the Generic
Data Structures lesson, whose declaration an instantiation sits in will be measured. In this
lesson, instantiation always happens on the caller’s side, by looking at the argument’s
static type — the function itself does not know which instantiations will be requested.
The var zero T line giving three different results across three separate instantiations comes
from the same mechanism. The Zero Values lesson (M09/K01) showed that every type finds its own
zero value defined — 0 for int, an empty string for string, 0.0 for float64. var zero T carries this definedness into the generic function: the source never says which zero value
will be produced at all, it only says “let T’s zero value sit here,” and the compiler fills
this promise once instantiation is built. While this line itself stays fixed in the declaration,
the value it fills comes from the use site at every instantiation — a small repetition of the
same distinction.
GE2. The firstElement source stays unchanged, not a single letter different, across three
calls; the only thing that changes is the element type of the slice the argument carries. %T
gives three different answers — int, string, float64 — but none of these three answers can
be found by searching the source, because the source never writes a name in T‘s place at all.
The compiler builds this binding, just like the Interface Concept lesson’s implicit
satisfaction, outside the source — but unlike an interface, it does not leave this decision
to run time, it gives it at compile time, by looking at the argument’s static type. This is what
the two bindings have in common: both are built at the use site. Their difference starts right
here too: interface conformance can be verified by asking the value (with %T), while a generic
function’s type parameter disappears once compilation finishes and is stored nowhere at all in
the running program — this distinction is measured again in the When Generics lesson.
Where Inference Falls Short
The compiler can read the type parameter only from the arguments; if there is no argument,
there is nothing to read either. zeroValue[T any]() T is the plainest example of this: the
function has no parameter at all, only a return type, and inference cannot be worked backward
from a return type. In the run above, the call zeroValue[string]() therefore carries T
explicitly in square brackets for exactly this reason; deleting [string] from this line
turns “which type’s zero value” into a question the compiler cannot answer, and the source does
not compile.
A second case where inference falls short can arise even when an argument does exist: if the same type parameter appears in more than one parameter, the compiler expects all of them to resolve to the same concrete type. The measurement below tests this by asking the compiler directly from within the program.
// limits.go — the call inference cannot handle and the limits of an unconstrained body, measured by asking the compiler
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
)
func compiles(source string) bool {
dir, err := os.MkdirTemp("", "trial")
if err != nil {
return false
}
defer os.RemoveAll(dir)
os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module trial\n\ngo 1.24\n"), 0o644)
os.WriteFile(filepath.Join(dir, "code.go"), []byte(source), 0o644)
cmd := exec.Command("go", "build", "./...")
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOTOOLCHAIN=local")
return cmd.Run() == nil
}
func classifySource(source string) string {
if compiles(source) {
return "compiled"
}
return "did not compile"
}
func main() {
mismatchedCall := `package trial
func Both[T any](a, b T) T { return a }
func F() { Both(3, "x") }`
explicitCall := `package trial
func Both[T any](a, b T) T { return a }
func F() { Both[any](3, "x") }`
comparison := `package trial
func Equal[T any](a, b T) bool { return a == b }`
addition := `package trial
func Add[T any](a, b T) T { return a + b }`
length := `package trial
func Length[T any](s []T) int { return len(s) }`
fmt.Printf("%-42s %s\n", "operation", "result")
fmt.Printf("%-42s %s\n", "inference: int and string to the same T", classifySource(mismatchedCall))
fmt.Printf("%-42s %s\n", "when T=any is written explicitly", classifySource(explicitCall))
fmt.Printf("%-42s %s\n", "unconstrained T in the body with == ", classifySource(comparison))
fmt.Printf("%-42s %s\n", "unconstrained T in the body with +", classifySource(addition))
fmt.Printf("%-42s %s\n", "len on an unconstrained T's slice", classifySource(length))
}
operation result inference: int and string to the same T did not compile when T=any is written explicitly compiled unconstrained T in the body with == did not compile unconstrained T in the body with + did not compile len on an unconstrained T's slice compiled
GE3. The first two lines are two spellings of the same call: Both(3, "x") does not compile
because the compiler tries to read T as int from the first argument and string from the
second, and cannot settle on a single type; Both[any](3, "x") compiles because the type
parameter is no longer left to inference at all, the caller writes any itself. Inference is
a convenience, not a requirement — the compiler always allows falling back to explicit writing;
only where inference cannot be done does explicit writing become mandatory.
This rejection comes from two parameters sharing the same type parameter — the signature
func Both[T any](a, b T) T imposes, as a declaration, that a and b resolve to the same
concrete type. If the signature had been written func Both2[T1, T2 any](a T1, b T2), a would
bind to int and b to string separately, and the call would compile; whether two variables
are allowed to be different types depends on how many separate names the type parameter list
carries. This confirms the binding’s position once again: how many type parameters will be
shared is written in the source (declaration); which concrete types those parameters correspond
to is built anew on every call (use site).
This distinction turns into a design decision in practice: if two parameters have to be the same type — for example in a comparison or a swap function — sharing a single type parameter hands this requirement over to the compiler; if the caller mistakenly passes two different types, the source does not compile at all. If two parameters have to be independent, opening separate type parameters prevents the compiler from imposing an unnecessary equality. The type parameter list in the source is therefore not merely a syntax detail, it is a small contract stating which arguments are tied to each other.
The Limiting Measurement: What Can Be Written in an Unconstrained Body
GE4. The last three lines measure a different question: once the type parameter is built at
the call site, what can be done with that parameter in the function’s body? any only means
“every type is accepted,” not “every operation can be written.” a == b does not compile,
because T‘s constraint is any and any guarantees no comparability — a slice or a function
also satisfies any, but neither can be compared with ==. a + b does not compile for the
same reason: + is defined only for numeric types and for strings; any guarantees none of
that. len(s) does compile, because this operation looks not at T itself but at the slice’s
type — the length of s is independent of the element type.
These three lines show that an unconstrained type parameter is nearly useless in the body: any
provides the widest acceptance but leaves the narrowest set of operations. This restriction is
not a shortcoming, it is the flip side of the course’s second claim: if a type implicitly
satisfying an interface is never written anywhere in the source, then the fact that the compiler
knows nothing at all about T is also never written anywhere in the source; any carries
this ignorance to its most extreme point.
The reason the len(s) line behaves differently from the other two is that this operation looks
not at T but at the slice’s own structure: a slice, whatever its element type, carries the
same three-field header, and the length is read from that header. Other built-in operations such
as index, range, cap belong to the same class — they never ask what type T is at all,
they only look at the structure the language itself guarantees. == and + are the exact
opposite: both call a behavior specific to T’s concrete type, and an unconstrained T does not
even guarantee that this behavior exists. The next lesson builds the mechanism that fills this
gap — the type constraint, which states in the declaration which operations a type
parameter allows; the built-in constraint named comparable makes the Equal example above
compile with a single word.
The entire five-line measurement was produced with a single tool named compiles: the same
method — writing the source to a temporary package and calling the compiler as a subprocess,
reading only the exit status — was also used in the Go’s Design Goals lesson. The method carries
the same two properties here as well — deterministic (the same source gives the same answer on
every run, because the compiler itself is being called) and portable (it depends only on the
standard library’s process-launching capability). Never reading the error text is a deliberate
boundary: this lesson measures not “why was it rejected” but “was it rejected,” because the form
of the error text the compiler produces is a version-dependent detail, and the course’s
criterion does not rest on that detail.
Summary
- A generic function’s type parameter is never written anywhere in the source at all; the compiler builds it at the call site, by looking at the static type of the arguments.
- Type-independent code, such as the zero-value logic in the function body, sits in the declaration and never changes on any call; the type parameter itself is rebuilt at the use site on every call.
- Inference is read only from the arguments: if there is no argument (a return type alone), or if the arguments do not settle on the same type, the type parameter must be written explicitly at the call site.
- An unconstrained type parameter (
any) allows, in the body, only operations valid for every type; operations such as==and+do not compile with an unconstrainedT. - All these measurements were made by asking the compiler itself: the error text was never read, only whether a source compiled or not was classified.
This boundary is not a shortcoming of generics, it is a natural consequence of their
definition: a generic function has to work, from a single source, for every T, and this is
possible only with operations independent of T’s concrete type. A constraint widens which
operations can be safely used, without breaking this independence.
Next Step
That == and + cannot be written with an unconstrained T may look like a shortcoming, but
it is actually the result of the compiler leaving everything it does not know about T
exactly as it is. The next lesson closes this ignorance with a declaration: a type constraint is
an interface that states explicitly in the source the set of types a type parameter accepts
and which operations that set guarantees — and this interface is used for a different job than
other interfaces.
To keep your progress and take notes, Log in
My notes
Log in to take notes.