Generieke methoden in Go 1.27

Toen Go 1.18 in 2022 generics introduceerde, bracht het generieke typeparameters naar functies en structs, maar liet het methoden buiten beschouwing. Met de release van Go 1.27 is deze langdurige beperking opgeheven. Methoden kunnen nu hun eigen typeparameters definiëren zonder deze aan de ontvangende struct toe te voegen.

Waarom deze wijziging is doorgevoerd

Als je een graafknooppunt (graph node) wilt maken dat een generieke waarde bevat, zou je dit als volgt kunnen implementeren:

type Node[T any] struct {
    value T
}

Stel dat je een methode Map wilt toevoegen die een knooppunt van type T transformeert naar een knooppunt van een ander type U. Vóór Go 1.27 was je gedwongen om U direct aan de Node-struct zelf toe te voegen:

type Node[T any, U any] struct {
    value T
}

func (n *Node[T, U]) Map() Node[T, U] {
    // ...
}

U toevoegen aan de struct zelf is een slecht ontwerp, omdat U een typeparameter is die specifiek is voor Map. Hoewel andere methoden geen gebruik maken van U, moesten ze deze nog steeds in hun receiver-declaraties behouden.

De enige omweg was om Map te implementeren als een functie op pakketniveau in plaats van als een methode, aangezien functies hun eigen typeparameters konden definiëren. Omdat methoden dit niet konden, leidde dit tot onhandige en niet-idiomatische API-ontwerpen.

Vanaf Go 1.27 kan U uitsluitend op de Map-methode worden gedefinieerd:

func (n *Node[T]) Map[U any]() Node[U] {
    // ...
}

Waarom heeft dit zo lang geduurd?

Het antwoord ligt in de manier waarop generics in Go zijn geïmplementeerd. De compiler handelt generics primair af via monomorfisatie. Dit betekent dat er een kopie van generieke structs of functies wordt gemaakt voor elk specifiek type waarmee ze worden gebruikt, omdat het abstracte concept van generics op een gegeven moment moet worden vertaald naar directe machinecode.

Het interfacesysteem van Go werkt echter tijdens runtime. Het specifieke type van een waarde die aan een interface-parameter wordt meegegeven, wordt pas bepaald terwijl het programma draait. Deze dynamische dispatch botst met generics, die tijdens het compileren worden opgelost. Overweeg wat er zou gebeuren als we de Map-methode in een interface zouden willen declareren:

type Mapper interface {
    Map[T any, U any](element T) U
}

Om dit werkend te krijgen, zou de runtime ofwel een Just-In-Time (JIT) compiler nodig hebben om on-the-fly machinecode voor specifieke typen te genereren, ofwel kopieën voor elk mogelijk type vooraf moeten maken, wat zou resulteren in een enorm opgeblazen binary.

Uiteindelijk heeft het Go-team besloten om generieke methoden te scheiden van interfaces. Go 1.27 staat generieke methoden toe op concrete typen, maar niet op interfaces. Dit onderscheid is de reden waarom het bovenstaande voorbeeld niet zal compileren en waarom de discussies hierover enige tijd in beslag namen.

Verdere lectuur

  • Go 1.27 Release Notes (go.dev)
  • Proposal: Generic Methods for Go (GitHub)
  • The Performance of Generics in Go (dominik.info)