Skip to content
academia.sh

Lesson 13 / 13

When Generics

The same total-area computation is built three ways: with the empty interface, three type assertions; with generics, zero, but T is pinned to a single concrete type; with an interface contract, also zero, but different concrete types can sit in the same slice. The limiting measurement shows that once T is pinned to an interface (to accept a heterogeneous collection), generics' gain disappears: element size rises from 8 bytes to 16 bytes, exactly like an ordinary interface value. The course found 4 of sixteen bindings in the declaration, 4 at the use site, 8 inside the value.

Published

In this lesson

  • Writes the same total computation with the empty interface, with generics, and with an interface contract, and measures each writing's type-assertion and declaration-line count
  • Shows, by measuring element size, that generics' gain disappears once the type parameter is pinned to an interface
  • Gathers, in the closing table, the distribution of the course's sixteen bindings across declaration, use site, and value
Contents

The Generic Data Structures lesson showed that genericity stayed inside the source across three lessons: the type parameter was either inferred or written explicitly in the declaration, but it never entered the value. The interfaces topic measured the exact opposite — the dynamic type was carried inside the value and could only be read by asking the value. This closing lesson places these two writings, and the interface contract standing between them, side by side by doing the same job three times: a total-area computation is built with the empty interface, with generics, and with an interface contract, and in all three, where the binding is built is counted.

This was the course’s third claim: generics and interfaces do the same job in two separate places. In generics the binding is built in the declaration and at the use site, it never enters the value; in an interface the binding enters the value. This lesson measures this difference for the first time by directly running it, writing the same computation three times.

The previous three lessons always measured generics on their own — never placed side by side with an interface. This left a debt in the course’s general framework: the claim “in generics the binding never enters the value” was, without a comparison being made, only an observation. This lesson closes that debt: the same job is written with both generics and an interface and measured in the same run, and the difference turns from a claim into a number (8 bytes against 16 bytes).

The Same Job, Three Writings

The job measured is simple: computing the total area of shapes in a collection. Square and Circle each carry their own Area() method; the Shape interface lists this single method. This job was chosen deliberately: the area computation genuinely varies by type — a square’s area is the side squared, a circle’s area is the radius squared times a constant — but the summing operation itself is the same for every type. The job all three writings have to accomplish therefore splits into two parts: the type-specific part (Area()’s own body) and the type-independent part (accumulating the total).

// three.go — the same job in three writings: empty interface, generics, interface contract
package main

import (
	"fmt"
	"unsafe"
)

type Square struct{ Side float64 }

func (s Square) Area() float64 { return s.Side * s.Side }

type Circle struct{ Radius float64 }

func (c Circle) Area() float64 { return 3.14159 * c.Radius * c.Radius }

type Shape interface{ Area() float64 }

// 1) with the empty interface: a type assertion (a type-switch branch) is needed for every element.
func totalAreaAny(s []any) (total float64, assertionCount int) {
	for _, e := range s {
		switch v := e.(type) {
		case Square:
			assertionCount++
			total += v.Area()
		case Circle:
			assertionCount++
			total += v.Area()
		}
	}
	return
}

// 2) with generics: the body is the same for every T, and as long as T stays homogeneous there is no type assertion at all.
func totalAreaGeneric[T Shape](s []T) (total float64) {
	for _, e := range s {
		total += e.Area()
	}
	return
}

// 3) with an interface contract: different concrete types in the same slice, no type assertion at all.
func totalAreaInterface(s []Shape) (total float64) {
	for _, e := range s {
		total += e.Area()
	}
	return
}

func main() {
	fmt.Println("-- same job, three writings --")

	mixed := []any{Square{Side: 2}, Circle{Radius: 1}, Square{Side: 3}}
	tAny, assertions := totalAreaAny(mixed)
	fmt.Printf("empty interface  total=%.4f  type assertions=%d  declaration lines=0\n", tAny, assertions)

	homogeneous := []Square{{Side: 2}, {Side: 3}}
	tGeneric := totalAreaGeneric(homogeneous)
	fmt.Printf("generic          total=%.4f  type assertions=0  declaration lines=1 (constraint)\n", tGeneric)

	contracted := []Shape{Square{Side: 2}, Circle{Radius: 1}, Square{Side: 3}}
	tInterface := totalAreaInterface(contracted)
	fmt.Printf("interface        total=%.4f  type assertions=0  declaration lines=1 (interface)\n", tInterface)

	fmt.Println()
	fmt.Println("-- limiting measurement: the gain disappears once T is pinned to an interface --")
	heterogeneous := []Shape{Square{Side: 2}, Circle{Radius: 1}}
	tDegenerate := totalAreaGeneric(heterogeneous)
	fmt.Printf("totalAreaGeneric[Square](homogeneous)   T=Square  element size=%d bytes  result=%.4f\n",
		unsafe.Sizeof(homogeneous[0]), tGeneric)
	fmt.Printf("totalAreaGeneric[Shape](heterogeneous)  T=Shape   element size=%d bytes  result=%.4f\n",
		unsafe.Sizeof(heterogeneous[0]), tDegenerate)
}
-- same job, three writings --
empty interface  total=16.1416  type assertions=3  declaration lines=0
generic          total=13.0000  type assertions=0  declaration lines=1 (constraint)
interface        total=16.1416  type assertions=0  declaration lines=1 (interface)

-- limiting measurement: the gain disappears once T is pinned to an interface --
totalAreaGeneric[Square](homogeneous)   T=Square  element size=8 bytes  result=13.0000
totalAreaGeneric[Shape](heterogeneous)  T=Shape   element size=16 bytes  result=7.1416

GE13. The first three lines compare three sources of the same computation. The empty interface writing runs a type assertion (a branch of the type switch) for all three of its three elements and declares no interface at all — the contract is written nowhere in the source, only the case branches inside the switch implicitly say which types are expected. The generic writing runs with zero type assertions, but homogeneous contains only Square — T, as the Generic Data Structures lesson measured, is pinned to a single concrete type in the caller’s own declaration. The interface writing also runs with zero type assertions, and moreover can carry Square and Circle in the same slice — contracted holds both at once, which homogeneous could not have held.

The Limiting Measurement: The Gain Disappears Once T Is Pinned to an Interface

GE14. The last two lines show the limit of genericity. Because totalAreaGeneric‘s constraint is a method list, it can also instantiate T with an interface type — totalAreaGeneric[Shape](heterogeneous) compiles and accepts Square and Circle in a mixed slice. But its cost is measured: with T=Square, every element is 8 bytes (just the Side field itself); with T=Shape, every element is 16 bytes — the sum of the type pointer and the data pointer an interface pair carries. Pinning T to an interface breaks genericity’s promise of zero cost: the code is now running something no different at all from totalAreaInterface, it has just hidden this behind a type parameter.

totalAreaGeneric’s body stayed letter-for-letter the same across both calls — the line for _, e := range s { total += e.Area() } changed for neither T=Square nor T=Shape. What changed was only what T was, and this change doubled the element size. This comes from the very definition of genericity: a generic body, from a single source, runs the same operation for every T it is instantiated with. With T=Square, this operation calls Square.Area() directly, with no extra layer at all; with T=Shape, the same operation now makes an indirect method call through an interface value — even though not a single line changed in the source, what happens at run time changes.

This gives a definite answer to the course’s question: generics do not always win. If a collection’s elements are all the same concrete type, and the operation performed on them is the same for every type, generics provide both zero type assertions and zero extra bytes. If the elements have to carry different concrete types — if behavior genuinely has to vary by type — two choices remain: pin T to an interface and give up genericity’s gain, or write an interface from the start. The second is the more honest writing, because the source already says what is going to happen — that more than one concrete type will sit in the same place. A generic body, by definition, stays the same for every T it is instantiated with; wanting it to behave differently for different Ts contradicts genericity itself.

Course Wrap-Up

Interfaces, Errors and Generics opened with a single question: in Go, a type satisfying an interface is never written anywhere at all — so where is this binding built? Thirteen lessons asked this question across three mechanisms: in interfaces (implicit satisfaction, the empty interface, type assertion, embedding, consumer-side definition), in error values (the error interface, sentinel errors, wrapping, panic), and in generics (the type parameter, the type constraint, generic data structures, and this lesson’s comparison of all three).

The first claim was confirmed: eight of sixteen bindings are carried inside the value, because a Go interface value is a pair — both a concrete type and data. The second claim was confirmed: four bindings are built at the use site, and the source never says anywhere at all that a type satisfies an interface. The third claim was run directly in this lesson: generics and interfaces do the same job in two separate places — in generics the binding stays in the declaration and at the use site, in an interface it enters the value — and this gain of generics disappears the moment the type parameter is pinned to an interface.

One of these three mechanisms, “use site,” carries a warning: the use site is where the compiler looks, not where the source points. The Generic Functions lesson proved this by asking the compiler directly — writing the source to a temporary package and reading only whether it passed; the Generic Data Structures and Type Constraints lessons used the same method. This difference between an indirect proof (only showing that a contract is not written in the source) and a direct proof (seeing, by running it, where the compiler stops) is part of the course’s own measurement discipline: if a lesson says “this is built at the use site,” it must either call the compiler and show where it stops, or explicitly write that the proof is indirect.

The course closes without overgeneralizing: these three mechanisms (declaration, use site, value) do not cover all of Go’s type system, they only classify the answers given to the single question these thirteen lessons asked — where is this binding built? The table below gathers these thirteen answers into a single row each.

Lesson Binding measured Where it is built (declaration · use site · value) Limiting measurement
The Interface Concept A type implicitly satisfying an interface; the small-interface principle use site (the 4/4/8 distribution was produced here) The compile-time conformance proof carries the binding into the declaration; without the proof, the compiler stays silent
The Empty Interface and any The empty interface carrying type information into the value value An interface holding a nil pointer is not nil
Type Assertion and Type Switch Asking the value: the two-value assertion, the branch a type switch selects value The same value can fit both a concrete and an interface branch; the branch chosen depends on write order
Interface Embedding Contract composition declaration Embedding merges only the contract, not the implementation
Interface Design The same concrete type satisfying four independently defined consumer interfaces; zero references in its own source use site Two separately named interfaces with the same signature become implicitly equivalent; equivalence breaks only once the signature diverges
The Error Interface An error being an implicitly satisfied value use site + value A non-nil interface can carry a nil concrete value
Sentinel Errors Error identity sitting in the value value Once identity changes, the caller silently falls into the wrong branch, producing no warning
Error Wrapping and Unwrapping The chain being carried in the value value Using %v breaks the chain, the text stays the same
Panic and Recovery A panic leaving no trace at all in the signature (no binding is built) Recovering does not destroy the panic, it carries it; if the recovered value is not written down, the information is lost entirely
Generic Functions A type parameter inferred at the call site use site == and + cannot be written with an unconstrained T
Type Constraints A constraint listing a type set declaration An interface with a type-set term is valid only in constraint position
Generic Data Structures Instantiation sitting in the caller’s declaration declaration Comparability also depends on the container’s own field structure
When Generics Where the same job binds across three writings (comparison of all three) Once T is pinned to an interface, generics’ gain disappears

The three mechanisms measured across the course ultimately reduce to a single habit: asking, while reading a source, “where is this binding built?” If a declaration line is searched for and found, the binding is there; if not, two places remain — either a binding the compiler builds at the use site (the only way to see this is to call the compiler), or a binding that can only be learned by asking the running value. Asking these three questions in order is a measurable way of separating what Go’s type system says in the source from what it does not, while reading it.

Summary

  • The same total-area computation was built three ways: the empty interface required three type assertions, generics and the interface ran with zero type assertions.
  • Generics stay zero-cost only when T is pinned to a single concrete type; once T is pinned to an interface, element size rises from 8 bytes to 16 bytes and the gain disappears.
  • An interface is needed when behavior genuinely has to vary by type, because a generic body stays the same for every T it is instantiated with.
  • Of the sixteen bindings measured across the course, four were built in the declaration, four at the use site, eight inside the value; this distribution classifies not Go’s type system, only the question these thirteen lessons asked.
  • Generics and interfaces do the same job in two separate places: in generics the binding never enters the value at all, in an interface it does — and this difference is the measurable counterpart of when to choose which.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close