Go’s type systeem vereist dat elk concreet type een eindige, statisch bepaalbare methodeset heeft om O(1) interface dispatch te mogelijk te maken. Als een methode op een niet-generic ontvanger zijn eigen typeparameters zou kunnen verklaren — zoals func (t *MyType) Process[T any](x T) — zou het type theoretisch een oneindige methodeset vertonen, lui geïnstantieerd voor elke mogelijke typeargument T.
Dit ontwerp zou de garanties van de itab (interface table) indeling tenietdoen, die afhankelijk zijn van vaste offsets voor methode pointers. Door typeparameters te beperken tot de type-definitie zelf (bijv. type MyType[T any] struct{}), zorgt Go ervoor dat elke unieke instantie een complete, eindige metadata-tabel produceert tijdens de compileertijd. Dit behoudt de voorspelbaarheid van de binaire grootte en handhaaft de prestatiekenmerken van interface-aanroepen via statische dispatch.
Tijdens het architecten van een hoogdoorvoer telemetrie-pijplijn, had ons team een gecentraliseerde MetricCollector nodig die verschillende datatypes kon verwerken — tellers, histogrammen en meters — terwijl compileertijd typeveiligheid werd behouden. We wilden aanvankelijk een API die leek op collector.Record[T Metric](value T), waarbij MetricCollector een concreet type bleef om gebruikers niet te dwingen de collector zelf te parameteriseren.
Het probleem ontstond onmiddellijk: Go weigerde de typeparameter op methodeniveau, waardoor we moesten kiezen tussen type-erasure (opslag van any en casten) of fragmentatie van de collector in meerdere generieke instanties. We hebben drie verschillende benaderingen geëvalueerd.
Eerst overweegden we MetricCollector te verhogen naar een generiek type MetricCollector[T Metric]. Dit zou de methode func (mc *MetricCollector[T]) Record(value T) mogelijk maken. Voordelen: Volledige typeveiligheid en opslag zonder allocatie. Nadelen: Gebruikers hadden aparte verzamelinstanties nodig voor tellers versus meters, waardoor de mogelijkheid om gemengde statistieken in een enkele register te aggeren zonder interface boxing verviel.
Ten tweede onderzochten we het genereren van code met go:generate om gemonomeerde methoden zoals RecordCounter, RecordGauge, enz. te creëren voor elk metriektype. Voordelen: Eén verzamelaarinstantie met typeveilige methoden. Nadelen: Complexiteit tijdens het bouwen, opgeblazen bronbeheer, en de noodzaak om de code te regenereren wanneer er nieuwe metriektypes verschenen.
Ten derde schakelden we over naar een pakket-niveau generieke functie func Record[T Metric](c *MetricCollector, value T). Deze benadering decoupleerde de typeparameter van de ontvanger. Voordelen: Behoud van een enkele verzamelinstanties, typeveiligheid door compiler-monomeerisatie van de functie, en vermijding van interface overhead. Nadelen: Iets minder idiomatische "object-georiënteerde" syntaxis, waardoor gebruikers de verzamelaar als een expliciete argument moesten doorgeven in plaats van als een methode-ontvanger.
We selecteerden de derde oplossing omdat deze de API ergonomie balanceerde met Go’s architectonische beperkingen. Het resultaat was een verzamelaar die in staat was om heterogene metriektypes via een uniforme interface te verwerken, met alle type mismatches die tijdens de compileertijd werden opgevangen in plaats van tijdens productiedistributies.
type Metric interface { Type() string } type MetricCollector struct { storage map[string][]any } // Ongeldig: func (mc *MetricCollector) Record[T Metric](value T) // Geldig: Generieke functie met expliciete verzamelaarargument func Record[T Metric](mc *MetricCollector, value T) { key := value.Type() mc.storage[key] = append(mc.storage[key], value) }
Waarom staat Go methoden toe zoals func (t *Tree[T]) Insert(x T) maar weigert func (t *Tree) Insert[T](x T)?
Wanneer de ontvanger zelf generiek is (Tree[T]), wordt de methodeset concreet geïnstantieerd voor elk specifiek typeargument (bijv. Tree[int] heeft een methode Insert(x int)). De methodeset blijft eindig omdat deze gebonden is aan de eindige set van instanties die in het programma aanwezig zijn. Voor een niet-generic ontvanger zou het toestaan van Insert[T] impliceren dat er een open-ended familie van methoden bestaat die is geïndexeerd door een oneindig type-universum, wat runtime methode-dictionaries of dynamische dispatch-tabellen vereist die de statische linking en snelle interface-aanroep garanties van Go schenden.
Hoe zou interface voldoening breken als concrete types generieke methoden zouden ondersteunen?
Interface voldoening in Go is afhankelijk van een statische controle: de compiler controleert of een type een interface implementeert door methodeschema’s te vergelijken. Als MyType Method[T]() zou kunnen implementeren, dan zou het voldoen aan interface { Method[int]() } verschillend zijn van interface { Method[string]() }. De compiler zou eindeloze vtable-variaties moeten genereren of voldoening controles naar runtime moeten uitstellen, waardoor interface-aanroepen van eenvoudige pointer offset opzoekingen naar dure dynamische resoluties worden getransformeerd, wat fundamenteel het prestatiemodel van de taal verandert.
Kunnen typeparameters worden gesimuleerd op concrete types met behulp van structvelden die generieke functies bevatten?
Ja, maar met kritische semantische afwegingen. Men kan type Processor struct { handle func[T any](T) } definiëren, maar dit slaat een concrete instantie van een functie op, niet een parameterized methode. Alternatief kan men een map van reflect.Type naar handler-functies opslaan. Voordelen: Runtime flexibiliteit. Nadelen: Verliest compileertijd typeveiligheid, incurren reflectie overhead, en breekt interface abstractie omdat de struct niet langer de methode in zijn methodeset heeft — alleen een veld — waardoor het type niet meer kan voldoen aan interfaces die die operatie vereisen.