31.6 Instantiation: How the Compiler Generates Code
Alright, let’s pull back the curtain on the real magic: instantiation. This is the moment where our generic function blueprint gets stamped out into a real, concrete, type-specific function that your CPU can actually execute. The compiler isn’t generating code at runtime; it’s doing all the heavy lifting right there while it compiles your program. Think of it less like a factory and more like a master baker who prepares all the ingredients before the party starts.
Here’s how it works: you, the developer, write a generic function. It’s a recipe that says “any type T that can do X.” When you actually call that function in your code with a specific type—say, int—the compiler goes, “Aha! They want the int version of this recipe.” It then generates a brand new, non-generic function specifically for int. This process is called instantiation, and it results in a function instantiation. It’s a one-time cost per unique type argument combination paid at compile time. Your runtime performance is identical to if you’d handwritten the damn thing yourself for every type. Zero overhead. It’s honestly a bit of a engineering marvel.
The Two-Step: Implicit vs. Explicit Instantiation
You’ve got two ways to trigger this compiler baking spree. The first, and most common, is implicit instantiation: you just call the function.
// Our generic blueprint
func Scale[S ~[]E, E constraints.Integer](s S, factor E) S {
result := make(S, len(s))
for i, v := range s {
result[i] = v * factor
}
return result
}
func main() {
intSlice := []int{1, 2, 3, 4}
// This call implicitly instantiates Scale[int, int]
scaled := Scale(intSlice, 2)
fmt.Println(scaled) // [2 4 6 8]
}
When the compiler hits that Scale(intSlice, 2) call, it deduces that S is []int and E is int. It then generates the specific Scale[int, int] function on the spot. You never see this generated code; it’s an internal compiler representation that gets compiled to machine code.
The second way is explicit instantiation, where you ask for the function without calling it. This gives you a value you can assign to a variable. Why would you do this? Mostly for passing the specific function around or when type inference needs a nudge.
func main() {
// Explicitly instantiate the function, but don't call it yet.
// This creates a function value of type func([]int64, int64) []int64
int64Scaler := Scale[int64]
// Now I can pass this specific function around my program
data := []int64{10, 20, 30}
result := int64Scaler(data, 3) // Now we call it.
fmt.Println(result) // [30 60 90]
}
Notice we only supplied the type argument for S ([]int64). The compiler, being the clever thing it is, can see that E must be int64 from the constraint S ~[]E. It fills in the blank for you. Neat, huh?
The Nitty-Gritty: What Actually Gets Generated?
Let’s be brutally honest: the generated code isn’t always as clean as you might hope. If you instantiate Scale for []int and int32, the compiler creates two entirely separate functions. They have no relation to each other. This is why you can’t, for example, assign a func([]int, int) []int to a variable of type func([]int32, int32) []int32. They’re different types. This is a common gotcha—the reusability of generic code happens at the source level, not the binary level. Your executable contains multiple copies of the logic. For most functions, this is a trivial size cost, but it’s something to keep in the back of your mind for truly enormous generic functions.
The Constraint Check: “Can This Type Even Work Here?”
Instantiation isn’t just a blind search-and-replace. This is where the compiler earns its keep. The moment it goes to generate Scale[SomeType], it first checks if SomeType satisfies the constraint. If it doesn’t, you get a compile-time error, right there at the call site. This is a huge win. It means you can’t accidentally try to Scale a []string—the constraint constraints.Integer will stop that nonsense before it even has a chance to become a bug.
func main() {
badSlice := []string{"a", "b", "c"}
// COMPILER ERROR: string does not satisfy constraints.Integer
Scale(badSlice, 2)
}
The error message is brilliantly direct. It tells you exactly which type argument broke the rules and which constraint it failed to satisfy. It’s the kind of clear, immediate feedback that makes generics in Go actually pleasant to work with. The compiler is your brilliant, pedantic friend, saving you from yourself.