Skip to content
academia.sh

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 unconstrained T.
  • 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.

Start typing to search.

↑↓ ↵ Esc navigate · open · close