Hlavní navigace

Dlouho očekávaná novinka v Go 1.18 – generické datové typy

29. 3. 2022
Doba čtení: 31 minut

Sdílet

 Autor: Go lang
Mnohými vývojáři netrpělivě očekávanou vlastností jazyka Go jsou (resp. byly) generické funkce a generické datové typy. Nakonec se objevily v nedávno vydané stabilní verzi Go 1.18.

Obsah

1. Dlouho očekávaná novinka v Go 1.18 – generické datové typy

2. Beztypové a jednoúčelové kontejnery

3. Statická a dynamická genericita

4. Generické datové typy v Javě a Rustu

5. Funkce s konkrétními datovými typy vs. s generickými typy

6. Použití prázdných rozhraní, které splňuje jakýkoli datový typ

7. Novinka v Go 1.18 – typové parametry

8. Explicitní volání konkrétní varianty generické funkce

9. Kontrola překladačem při explicitním volání konkrétní varianty generické funkce

10. Datový systém jazyka Go a přetížené operátory

11. Sada funkcí pro porovnání dvojice hodnot různých typů

12. Jediná funkce pro porovnání dvojice hodnot různých typů

13. Příprava generické funkce pro součet dvou hodnot

14. Generická forma funkce add

15. Problematika odvozených datových typů

16. Aproximace datového typu

17. Překlad běžné funkce compare do assembleru

18. Překlad funkce s typovými parametry do assembleru

19. Repositář s demonstračními příklady

20. Odkazy na Internetu

1. Dlouho očekávaná novinka v Go 1.18 – generické datové typy

Již v úvodní části seriálu o programovacím jazyku Go jsme si mj. řekli, že tento dnes dosti populární programovací jazyk byl navržen takovým způsobem, že obsahuje (resp. přesněji řečen původně obsahoval) pouze ty syntaktické a sémantické prvky, na nichž se shodli všichni tři původní autoři tohoto projektu. Všechny vlastnosti, u nichž nebylo na sto procent jisté, že jsou správně navrženy a že jejich implementace nebude zpomalovat překladač ani výsledný zkompilovaný a slinkovaný kód, nebyly do první verze programovacího jazyka Go přidány. Týká se to některých vlastností, které mohou programátorům chybět při řešení praktických úkolů – například výjimek, tříd (a na třídách postavené větvi objektově orientovaného programování) a taktéž generických datových typů a generických funkcí. A zejména neexistence generických datových typů může být pro některé typy aplikací značně omezující, protože je například složité implementovat obecné a současně i typově bezpečné datové struktury typu graf, strom, zásobník, fronta atd.

V dnešním článku se nejprve ve stručnosti seznámíme s tím, jakým způsobem jsou generické datové typy implementovány například v Javě a taktéž v Rustu, tedy v těch programovacích jazycích, jejichž niky se částečně (ale skutečně jen částečně) překrývají s oblastí použití programovacího jazyka Go. Ve druhé části článku si pak ukážeme, jak je tento problém řešen v Go 1.18, které přineslo právě podporu generických typů – což byla mnohými vývojáři netrpělivě očekávaná novinka (ta ovšem možná poněkud paradoxně vede ke zesložitění překladače i velikosti výsledného kódu; navíc není jednoduché takové funkce exportovat způsobem kompatibilním s céčkem či dalšími programovacími jazyky).

Poznámka: zabývat se tedy nebudeme jazyky typu Python, v nichž je použit duck typing a tedy zcela jiný přístup k práci s hodnotami a datovými strukturami.

Pro příklady omezení, které nám předchozí verze programovacího jazyka Go v některých případech kladly, nemusíme chodit daleko. Připomeňme si například články o frameworku Gonum, který do určité míry reflektuje možnosti balíčku Numpy pro Python. Zatímco v Numpy lze pracovat s vektory a maticemi, jejichž prvky jsou různých typů, v balíčcích Gonum je tomu jinak – zde se primárně pracuje s prvky typu float64, a to i v případech, kdy by z různých důvodů postačovalo použít prvky float32 nebo naopak complex64 popř. complex128.

Takto se pracuje s vektory, jejichž prvky jsou striktně omezeny na typ float64:

package main
 
import (
        "fmt"
        "gonum.org/v1/gonum/mat"
)
 
func main() {
        v1 := mat.NewVecDense(5, nil)
        v2 := mat.NewVecDense(5, []float64{1, 0, 2, 0, 3})
 
        fmt.Printf("dot(v1, v1): %f\n", mat.Dot(v1, v1))
        fmt.Printf("dot(v1, v2): %f\n", mat.Dot(v1, v2))
        fmt.Printf("dot(v2, v2): %f\n", mat.Dot(v2, v2))
        fmt.Printf("max(v2):     %f\n", mat.Max(v2))
        fmt.Printf("min(v2):     %f\n", mat.Min(v2))
        fmt.Printf("sum(v2):     %f\n", mat.Sum(v2))
}

A takto s maticemi:

package main
 
import (
        "fmt"
        "gonum.org/v1/gonum/mat"
)
 
func main() {
        d := mat.NewDiagDense(10, []float64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10})
        fmt.Printf("Value:\n%v\n\n", mat.Formatted(d))
 
        d.SetDiag(1, 100)
        fmt.Printf("Value:\n%v\n\n", mat.Formatted(d))
}
Poznámka: nejedná se tedy o příliš elegantní řešení.

2. Beztypové a jednoúčelové kontejnery

V některých programovacích jazycích (a nejedná se v žádném případě pouze o dynamicky typované jazyky) se setkáme s takzvanými beztypovými popř. s jednoúčelovými kontejnery, kterými můžeme do jisté míry nahradit kontejnery s generickými typy. Typicky se jedná o implementace seznamů, front, zásobníků, různých typů stromů, obecných orientovaných i neorientovaných grafů atd. Beztypové kontejnery jsou většinou založeny na vlastnostech třídního OOP (dědění a polymorfismus) a většinou taktéž na tom, že hierarchie tříd mívá v mnoha programovacích jazycích společného předka. To tedy znamená, že pokud vytvoříme kontejner (řekněme seznam) pro prvky typu „instance třídy na vrcholu hierarchie tříd“, bude možné do takové třídy uložit jakýkoli objekt, ovšem za tu cenu, že se ztrácí informace o uloženém typu (tu je nutné získávat pro každý prvek zvlášť, pokud to jazyk díky RTTI umožňuje – to ovšem není ani intuitivní, ani efektivní).

Podobné řešení nabízela Java před verzí 5.

Naopak je mnohdy možné vytvořit takzvané jednoúčelové kontejnery. Ty dokážou ukládat prvky jediného typu popř. v OOP jazycích i odvozeného typu (potomci třídy). Popř. se může jednat o prvky implementující či splňující nějaké rozhraní. Typickým příkladem může být pole či řez (slice) v programovacím jazyku Go. Při použití jednoúčelových kontejnerů se informace o typu prvků neztratí a naopak je striktně kontrolována překladačem. Nevýhoda je ovšem zřejmá – pro každý datový typ je mnohdy nutné vytvořit prakticky stejný kontejner, jehož implementace se mnohdy odlišuje pouze v několika maličkostech.

Poznámka: další nevýhodou je fakt, že se složitě vytváří dostatečně dobře použitelné knihovny nad takovými kontejnery, tedy knihovny s funkcemi, které by byly na jednu stranu obecné a na stranu druhou dobře použitelné (ideálně bez nutnosti použití RTTI). Například tato užitečná knihovna pro práci s řezy v jazyku Go mohla vzniknout až po zavedení generických datových typů. Taktéž knihovny srovnatelné s Numpy lze v Go v plné síle vytvořit až s využitím generických typů.

3. Statická a dynamická genericita

Generické datové typy a generické funkce lze realizovat rozličnými způsoby. Pokud samotný programovací jazyk genericitu nepodporuje, ovšem má rozumný makrosystém, lze použít (či možná spíše zneužít) právě tento makrosystém, kdy makra budou expandována na konkrétní datový typ (například komplexní číslo s položkami typu float), popř. na konkrétní funkci (s tím, že jméno funkce bude muset být nějakým způsobem unikátní). Jedná se ovšem o velmi křehké řešení – mnoho chybových hlášení bude používat expandovaný kód, který uživatel nenapsal, totéž někdy platí o ladění atd.

Poznámka: ideálně by se mělo jednat o makrosystém manipulující s AST a nikoli na úrovni zdrojového kódu.

V některých programovacích jazycích, například v Javě, se používá odlišný způsob, při němž se ve zdrojovém kódu konkrétní datový typ (vhodným způsobem) zapíše a překladač ho tedy zpracuje a použije pro případné typové kontroly. Ovšem interně – v generovaném kódu nebo (v případě Javy) bajtkódu – se použije nějaký obecný společný nadtyp, typicky třída Object (v závislosti na konkrétní hierarchii tříd a datových typů). Prakticky stejným způsobem je vyřešeno vytváření funkcí a metod z generických funkcí a metod.

Poznámka: existuje ještě třetí možnost, a to přímo generování zdrojového kódu. S touto možností jsme se již seznámili v souvislosti s projektem Genny. Krátce: jedná se o tu nejhorší možnou variantu, a to z mnoha pohledů. Po uvedení Go 1.18 je však možné na existenci tohoto projektu zcela zapomenout :-)

V případě, že překladač programovacího jazyka vytváří kód běžící ve virtuálním stroji, je možné generické datové typy popř. i generické funkce a metody vytvářet právě virtuálním strojem, a to přímo za běhu aplikace. Toto řešení má některé výhody (vytvoří se pouze tolik variant, kolik je skutečně zapotřebí), ovšem samozřejmě za tuto možnost zaplatíme pomalejším během a mnohdy i většími paměťovými nároky.

4. Generické datové typy v Javě a Rustu

Poznámka: pokud vás zajímají jen nové vlastnosti Go, můžete přeskočit na navazující kapitolu.

Nejprve se alespoň ve stručnosti podívejme na způsob použití generických datových typů v programovacím jazyku Java. Uvedeme si jeden z nejtypičtějších motivačních příkladů, na němž se například v učebnicích ukazují výhody generických datových typů v silně typovaných programovacích jazycích. V příkladu je vytvořen obecný seznam (jehož konkrétní implementace je založena na sekvenci prvků uložených v poli). Do tohoto seznamu můžeme vkládat libovolné objekty, přesněji řečeno instance jakékoli třídy. Proč tomu tak je? Seznam je kontejnerem pro objekty typu Object a právě třída Object leží na vrcholu hierarchie všech tříd Javy (jedná se o stromovou strukturu s jediným kořenem). Platí zde tedy jeden z principů třídního OOP – potomek může nahradit předka. Ve zdrojovém kódu vidíme, že do seznamu lze vložit i celé číslo, ovšem v tomto případě je interně použit takzvaný boxing, v němž je numerická hodnota nahrazena objektem, zde konkrétně instancí třídy Integer:

import java.util.List;
import java.util.ArrayList;
import java.awt.Color;
 
public class Test1 {
    public static void main(String[] args) {
        List l = new ArrayList();
        l.add(new Object());
        l.add("foobar");
        l.add(42);
        l.add(Color.green);
 
        for (Object i : l) {
            System.out.println(i);
        }
    }
}

Výše uvedené řešení má mnoho nevýhod. Překladač (nikoli ovšem runtime) ztrácí informace o tom, jaké typy objektů jsou vlastně v seznamu uloženy. Proto například není možné přeložit následující program, a to přesto, že sémanticky je zdánlivě v pořádku – do seznamu jsme uložili pouze řetězce, takže by mělo být možné volat pro všechny prvky seznamu metodu length(). Ovšem z pohledu překladače jsou všechny prvky (netypového) seznamu typu Object, takže to přímo možné není:

import java.util.List;
import java.util.ArrayList;
import java.awt.Color;
 
public class Test2 {
    public static void main(String[] args) {
        List l = new ArrayList();
        l.add("foo");
        l.add("bar");
        l.add("baz");
 
        String s = l.get(0);
        System.out.println(s.length());
    }
}

Výsledek pokusu o překlad dopadne neslavně:

Test2.java:8: warning: [unchecked] unchecked call to add(E) as a member of the raw type List
        l.add("foo");
             ^
  where E is a type-variable:
    E extends Object declared in interface List
Test2.java:9: warning: [unchecked] unchecked call to add(E) as a member of the raw type List
        l.add("bar");
             ^
  where E is a type-variable:
    E extends Object declared in interface List
Test2.java:10: warning: [unchecked] unchecked call to add(E) as a member of the raw type List
        l.add("baz");
             ^
  where E is a type-variable:
    E extends Object declared in interface List
Test2.java:12: error: incompatible types
        String s = l.get(0);
                        ^
  required: String
  found:    Object
1 error
3 warnings
Poznámka: navíc je ještě nutné počítat s tím, že namísto objektu může být do seznamu uložena hodnota null, takže v takovém případě by příklad zhavaroval na populární výjimce typu java.lang.NullPointerException.

Lepší řešení spočívá ve specifikaci typu prvků kolekce. Jedná se o informaci použitou překladačem, která mj. umožňuje provádět lepší typovou kontrolu:

import java.util.List;
import java.util.ArrayList;
import java.awt.Color;
 
public class Test5 {
    public static void main(String[] args) {
        List<String> l = new ArrayList<String>();
        l.add("foo");
        l.add("bar");
        l.add("baz");
        l.add(Integer.toString(42));
 
        for (String s : l) {
            System.out.println(s.length());
        }
    }
}
Poznámka: v novějších verzích Javy je možné zdrojový kód ještě nepatrně zkrátit a zpřehlednit s využitím „diamantu“ – druhá specifikace typu seznamu se nahradí pouze zápisem <>.

V Javě se navíc setkáme s takzvaným type erasure. Jedná se o odstranění informace o generickém typu překladačem při vytváření bajtkódu. To má několik důsledků – striktní typová kontrola je prováděna v době překladu (compile time), ovšem typová informace (například o typu prvků kontejneru) je v čase běhu (runtime) ztracena.

import java.util.Collection;
import java.util.ArrayList;
import java.awt.Color;
 
public class Test6 {
    public static void main(String[] args) {
        Collection<String> l1 = new ArrayList<String>();
        Collection<Integer> l2 = new ArrayList<Integer>();
 
        System.out.println(l1.getClass().getName());
        System.out.println(l2.getClass().getName());
 
        System.out.println(l1.getClass() == l2.getClass());
    }
}

Po spuštění tohoto demonstračního příkladu získáme dvakrát stejné jméno třídy. Na posledním řádku je patrné, že jsou třídy (z pohledu virtuálního stroje v Runtime) skutečně shodné:

java.util.ArrayList
java.util.ArrayList
true

Generické datové typy jsou podporovány i dalším (i když mnohdy nepřímým) konkurentem Go – programovacím jazykem Rust. Generické typy v Rustu mohou při správném použití zjednodušit tvorbu znovupoužitelného programového kódu a současně zajistit silnou typovou kontrolu při překladu (což jsou bez použití generických typů mnohdy současně nesplnitelné požadavky).

Programovací jazyk Rust například podporuje definici struktury představující komplexní čísla, přičemž typy složek jsou generické (znak T není klíčovým slovem, ovšem je v kontextu generických datových typů často používán, takže tento úzus taktéž dodržíme):

struct Complex<T> {
    real: T,
    imag: T,
}

Tento zápis znamená, že se za T při překladu doplní konkrétní rozpoznaný datový typ, což si ostatně můžeme snadno vyzkoušet:

fn main() {
    let c1 = Complex{real:10, imag:20};
    let c2 = Complex{real:10.1, imag:20.1};
    let c3 = Complex{real:10.2f64, imag:20.2f64};
    let c4 = Complex{real:true, imag:false};
 
    println!("{}+{}i", c1.real, c1.imag);
    println!("{}+{}i", c2.real, c2.imag);
    println!("{}+{}i", c3.real, c3.imag);
    println!("{}+{}i", c4.real, c4.imag);
}

V programovacím jazyku Rust je možné kromě deklarace generických datových typů vytvářet i generické funkce, tj. funkce, u nichž lze specifikovat parametrizovatelné typy argumentů i návratový typ. Podívejme se nyní na sice poněkud umělý, ale o to kratší demonstrační příklad. V tomto příkladu nejprve deklarujeme výčtový typ a následně funkci, která akceptuje dva parametry typu i32 (celé číslo se znaménkem) a třetí parametr, na základě jehož hodnoty funkce vrátí buď první či druhý parametr. Nejprve si povšimněte, jak se používá výčtový typ (má vlastní jmenný prostor, proto se zapisuje stylem Item::First a nikoli pouze First). Použití konstrukce match je v tomto případě idiomatické a mnohem lepší, než pokus o použití if, a to z toho důvodu, že překladač sám zkontroluje, zda v konstrukci match korektně reagujeme na všechny možné vstupy (což samozřejmě děláme :-):

enum Item {
    First,
    Second,
}
 
fn select_item(first_item:i32, second_item:i32, item:Item) -> i32 {
    match item {
        Item::First  => first_item,
        Item::Second => second_item,
    }
}
 
fn main() {
    let x = 10;
    let y = 20;
    println!("1st item = {}", select_item(x, y, Item::First));
    println!("2nd item = {}", select_item(x, y, Item::Second));

}

Funkce select_item v podobě, v jaké jsme si ji ukázali, není příliš použitelná ani obecná, protože ji ve skutečnosti lze volat pouze s parametry typu i32. Pokusme se tedy vytvořit podobnou funkci, ovšem generickou. V tomto případě to znamená, že typy prvních dvou parametrů musí být shodné a musí odpovídat návratovému typu funkce – ta totiž nemá provádět žádné konverze, pouze vybírat mezi prvním a druhým argumentem. Takto navržená generická funkce může vypadat následovně (povšimněte si především zápisu <T> za jménem funkce):

fn select_item<T>(first_item:T, second_item:T, item:Item) -> T {
    match item {
        Item::First  => first_item,
        Item::Second => second_item,
    }
}
Poznámka: s oběma koncepty, tedy jak s generickými funkcemi, tak i s generickými datovými typy, se setkáme v jazyku Go a tím pádem i v navazujícím textu.

5. Funkce s konkrétními datovými typy vs. s generickými typy

Abychom pochopili, v jakých situacích může být použití generických funkcí a generických datových typů užitečné, je vhodné si nejdříve zrekapitulovat základní vlastnosti funkcí v programovacím jazyku Go z pohledu typového systému tohoto jazyka. Pokud se nejedná o generickou funkci, je funkce kromě svého jména jednoznačně určena i počtem, pořadím a typem parametrů. Jméno funkce v daném balíčku musí být unikátní. To mj. znamená, že taková funkce se přeloží do strojového kódu v jediné variantě:

package main
 
import "fmt"
 
func printValue(value string) {
        fmt.Println(value)
}
 
func main() {
        printValue("www.root.cz")
}

Funkce není možné přetěžovat (na rozdíl od mnoha dalších jazyků typu C++ či Javy):

package main
 
import "fmt"
 
func printValue(value string) {
        fmt.Println(value)
}
 
func printValue(value int) {
        fmt.Println(value)
}
 
func main() {
        printValue("www.root.cz")
}

Takový kód nelze přeložit:

./02_print_overload.go:9:6: printValue redeclared in this block
        ./02_print_overload.go:5:6: other declaration of printValue

A navíc je typový systém Go velmi striktní s ohledem na odvozené typy – hodnota odvozeného typu není automaticky konvertována na původní (bázový) typ. Ani následující kód tedy není korektní:

package main
 
import "fmt"
 
type Value string
 
func printValue(value Value) {
        fmt.Println(value)
}
 
func main() {
        v := "www.root.cz" // string
        printValue(v)
}

Tento kód opět nelze přeložit:

./03_print_no_conversion.go:13:13: cannot use v (variable of type string) as type Value in argument to printValue

6. Použití prázdných rozhraní, které splňuje jakýkoli datový typ

V některých situacích je možné vytvořit prakticky použitelnou funkci, která akceptuje parametr či parametry typu interface{}. V jazyce Go každý datový typ uspokojuje (satisfy) toto rozhraní, a to z toho prostého důvodu, že implementuje všechny metody předepsané v tomto rozhraní – žádné takové metody ve skutečnosti neexistují, takže je jejich implementace prostá všech programátorských chyb a výsledný kód je velmi efektivní :-):

package main
 
import "fmt"
 
func printValue(value interface{}) {
        fmt.Println(value)
}
 
func main() {
        printValue("www.root.cz")
        printValue('*')
        printValue(42)
        printValue(3.14)
        printValue(1 + 2i)
        printValue([]int{1, 2, 3})
}

V praxi je však mnohdy nutné uvnitř takové funkce kontrolovat skutečný typ parametru. A možná ještě horší je nemožnost rozumným způsobem zapsat funkci typu:

func add(x, y interface{}) interface{} {
}

a očekávat, že pokud se funkce zavolá například s parametry typu int, odvodí si překladač, že výsledkem bude taktéž hodnota typu int.

7. Novinka v Go 1.18 – typové parametry

Do programovacího jazyka Go verze 1.18 byla přidána podpora pro takzvané typové parametry, s jejichž využitím lze realizovat generické funkce. Začneme tím nejjednodušším příkladem, konkrétně definicí funkce akceptující hodnotu jakéhokoli typu. Informace o tomto typu je zapsána právě formou typového parametru – viz první podtrženou část definice. A tento typ je posléze použit pro určení typu parametru funkce (druhá podtržená část kódu):

func printValue[T any](value T) {
        fmt.Println(value)
}
Poznámka: „any“ se ve Scale nazývá „top type“, zatímco jeho přesný opak „nothing“ je „bottom type“. Všechny další datové typy leží mezi těmito dvěma extrémy (v Go se takové označení prozatím pravděpodobně neujalo).

Vraťme se však k upravené definici funkce printValue. Tu lze volat s parametrem jakéhokoli typu:

package main
 
import "fmt"
 
func printValue[T any](value T) {
        fmt.Println(value)
}
 
func main() {
        printValue("www.root.cz")
        printValue('*')
        printValue(42)
        printValue(3.14)
        printValue(1 + 2i)
        printValue([]int{1, 2, 3})
}

8. Explicitní volání konkrétní varianty generické funkce

Při volání funkce je možné si zvolit, jakou variantu generické funkce chceme volat. Interně totiž (což si ukážeme dále) překladač vygeneruje strojový kód pro všechny použité varianty). Výběr konkrétní varianty volaného kódu se provádí opět s využitím hranatých závorek, tentokrát ovšem při volání funkce:

package main
 
import "fmt"
 
func printValue[T any](value T) {
        fmt.Println(value)
}
 
func main() {
        printValue[string]("www.root.cz")
        printValue[rune]('*')
        printValue[int](42)
        printValue[float32](3.14)
        printValue[complex64](1 + 2i)
        printValue[[]int]([]int{1, 2, 3})
}
Poznámka: na posledním řádku jsou hranaté závorky použity ve dvou různých významech – pro určení varianty generické funkce a pro určení, že se má použít varianta akceptující řez prvků typu int.

9. Kontrola překladačem při explicitním volání konkrétní varianty generické funkce

Explicitní výběr zavolání konkrétní varianty generické funkce je sice zcela ponechán na vůli vývojáře, ovšem skutečné typy parametrů musí zvolené variantě odpovídat, což kontroluje překladač (nikoli runtime):

package main
 
import "fmt"
 
func printValue[T any](value T) {
        fmt.Println(value)
}
 
func main() {
        printValue[int]("www.root.cz")
        printValue[[]string]('*')
        printValue[string](42)
        printValue[int](3.14)
        printValue[byte](1 + 2i)
        printValue[[]byte]([]int{1, 2, 3})
}

Nyní ovšem překladač vypíše mnoho chyb, protože se snažíme volat konkrétní variantu generické funkce, ovšem s nesprávnými typy parametrů:

./05_type_parameter_check.go:10:18: cannot use "www.root.cz" (untyped string constant) as int value in argument to printValue[int]
./05_type_parameter_check.go:11:23: cannot use '*' (untyped rune constant 42) as []string value in argument to printValue[[]string]
./05_type_parameter_check.go:12:21: cannot use 42 (untyped int constant) as string value in argument to printValue[string]
./05_type_parameter_check.go:13:18: cannot use 3.14 (untyped float constant) as int value in argument to printValue[int] (truncated)
./05_type_parameter_check.go:14:19: cannot use 1 + 2i (untyped complex constant (1 + 2i)) as byte value in argument to printValue[byte] (truncated)
./05_type_parameter_check.go:15:21: cannot use []int{…} (value of type []int) as type []byte in argument to printValue[[]byte]

10. Datový systém jazyka Go a přetížené operátory

Jeden z problémů řešitelných generickými funkcemi a generickými datovými typy spočívá v zápisu funkcí, v nichž je použit nějaký přetížený operátor. Příkladem přetíženého operátoru je operátor <, který lze použít pro porovnání celých čísel, čísel s plovoucí řádovou čárkou, komplexních čísel či dokonce řetězců. Příkladem použití může být porovnání dvou celých čísel v uživatelem definované funkci nazvané (nepříliš nápaditě a korektně) compare:

package main
 
import "fmt"
 
func compare(x int, y int) bool {
        return x < y
}
 
func main() {
        fmt.Println(compare(1, 2))
}

V navazujících dvou kapitolách si ukážeme, jak lze tuto funkci zobecnit tak, aby dokázala například porovnat dva řetězce.

11. Sada funkcí pro porovnání dvojice hodnot různých typů

Jeden ze způsobů zobecnění funkce compare spočívá v reimplementaci toho samého algoritmu pro každý podporovaný datový typ. Každou z variant funkce je přitom nutné jednoznačně pojmenovat. V žádném případě se nejedná o elegantní řešení, ovšem je ho možné využít i ve starších verzích programovacího jazyka Go:

package main
 
import "fmt"
 
func compareInts(x int, y int) bool {
        return x < y
}
 
func compareFloats(x float64, y float64) bool {
        return x < y
}
 
func compareStrings(x string, y string) bool {
        return x < y
}
 
func main() {
        fmt.Println(compareInts(1, 2))
        fmt.Println(compareFloats(1.5, 2.6))
        fmt.Println(compareStrings("foo", "bar"))
}

12. Jediná funkce pro porovnání dvojice hodnot různých typů

S využitím generických datových typů a současně i typových parametrů můžeme deklarovat jedinou funkci, která bude korektně porovnávat dvojici hodnot, přičemž typy těchto hodnot si sami zvolíme. Nejdříve musíme definovat množinu typů obou parametrů funkcecompare. Tuto množinu nazveme například comparable. Zápis (syntaxe) je novinkou:

type comparable interface {
        int | float64 | string
}

Tímto zápisem jsme si usnadnili zápis hlavičky funkce compare, která bude vypadat následovně:

func compare[T comparable](x T, y T) bool {
        return x < y
}

Takový zápis určuje, že oba parametry musí být stejného typu, ovšem tento typ může být při konkrétním volání funkce int, float64 nebo string. Překladač navíc (pochopitelně) zkontroluje, zda je možné operátor < použít pro každou kombinaci typů parametrů.

Příklad praktického (a zcela korektního) použití této funkce:

package main
 
import "fmt"
 
type comparable interface {
        int | float64 | string
}
 
func compare[T comparable](x T, y T) bool {
        return x < y
}
 
func main() {
        fmt.Println(compare(1, 2))
        fmt.Println(compare(1.5, 2.6))
        fmt.Println(compare("foo", "bar"))
}

13. Příprava generické funkce pro součet dvou hodnot

Před vydáním programovacího jazyka Go verze 1.18 nebyly generické datové typy ani generické funkce v programovacím jazyce Go přímo podporovány. Zkusme se tedy nyní podívat, jaké další problémy neexistence generických typů může přinášet (resp. přesněji řečeno mohla přinášet a mnohdy i přinášela). Začněme zcela jednoduchou funkcí určenou pro součet dvou celých čísel. Tu lze zapsat a zavolat takto:

package main
 
import "fmt"
 
func add(x int, y int) int {
        return x + y
}
 
func main() {
        fmt.Println(add(1, 2))
}

Pokud budeme chtít tuto funkci zobecnit, aby sečetla numerické hodnoty libovolného typu a vrátila typově správný výsledek, poměrně (pokud nepoužijeme generické typy) brzy narazíme. Už jen z toho důvodu, že funkce (jejich názvy) nelze v Go přetěžovat, takže v jednom modulu nemůžeme vytvořit funkci stejného jména, pouze s jinými typy parametrů a návratové hodnoty. Tuto funkci již tedy nebude možné do stejného modulu přidat:

func add(x float32, y float32) float32 {
    return x + y
}

Toto chování programovacího jazyka Go si ostatně můžeme velmi snadno otestovat, a to konkrétně na následujícím příkladu:

package main
 
import "fmt"
 
func add(x int, y int) int {
        return x + y
}
 
func add(x float32, y float32) float32 {
        return x + y
}
 
func main() {
        fmt.Println(add(1, 2))
        fmt.Println(add(1.1, 2.2))
}

Pokus o překlad tohoto demonstračního příkladu skončí s chybou:

$ go build add2.go 
 
# command-line-arguments
./add2.go:9:6: add redeclared in this block
        previous declaration at ./add2.go:5:24
Poznámka: na druhou stranu lze toto omezení chápat. To, že funkce lze přetěžovat (například v C++) přináší i mnohé problémy související mj. se jmény funkcí (Name mangling) a tím pádem i s podporou takových funkcí a metod v IDE, debuggerech apod. To stejné lze říci i o generických datových typech, pokud se jejich jména exportují (ještě jinými slovy – jazyk C a jeho zvyklosti tady s námi bude ještě hodně dlouho a bude nepřímo ovlivňovat i další programovací jazyky).

14. Generická forma funkce add

Přechod od jediné konkrétní implementace funkce add ke generické implementaci již nebude složitý. Některé přípravné kroky jsme ostatně udělali v rámci předchozích kapitol. Nejprve tedy budeme definovat novou množinu typů, která bude určovat povolený typ parametrů nově vznikající funkce:

type numeric interface {
        int | float64 | complex128
}

Následně napíšeme funkci, která bude akceptovat dva parametry typu int/float64/complex128 a vracet bude hodnotu stejného typu – oba parametry tedy musí (pro dané volání funkce) být současně typu int nebo současně typu float64 atd. – a totéž platí pro výsledek:

func add[T numeric](x T, y T) T {
        return x + y
}

Takové funkci již můžeme předat různé parametry:

package main
 
import "fmt"
 
type numeric interface {
        int | float64 | complex128
}
 
func add[T numeric](x T, y T) T {
        return x + y
}
 
func main() {
        fmt.Println(add(1, 2))
        fmt.Println(add(1.5, 2.6))
        fmt.Println(add(1i, 2+4i))
}
Poznámka: rozšíření funkce je možné například i pro typ string, který rovněž podporuje operátor +.

15. Problematika odvozených datových typů

Výše uvedená funkce je sice generická a bude pracovat korektně pro parametry typu int, float64 nebo complex128, ovšem již z páté kapitoly víme, že následující zápis je nekorektní, protože typ Value je sice odvozený od typu string, ale konverze se neprovádí automaticky (což je mimochodem jedna z nejlepších praktických vlastností jazyka Go):

type Value string
 
func printValue(value Value) {
        fmt.Println(value)
}
 
func main() {
        v := "www.root.cz" // string
        printValue(v)
}

Totéž bude platit i pro naši generickou funkci, která z tohoto pohledu není až tak generická, jak by to bylo možné:

package main
 
import "fmt"
 
type numeric interface {
        int | float64 | complex128
}
 
func add[T numeric](x T, y T) T {
        return x + y
}
 
type myInt int
 
type myFloat float64
 
type myComplex complex128
 
func main() {
        var x myInt = 42
        var y myFloat = 3.14
        var z myComplex = 1 + 2i
 
        fmt.Println(add(x, x))
        fmt.Println(add(y, y))
        fmt.Println(add(z, z))
}

Funkci není možné volat s parametry typu myInt atd.:

./13_add_type_parameters.go:24:17: myInt does not implement numeric (possibly missing ~ for int in constraint numeric)
./13_add_type_parameters.go:25:17: myFloat does not implement numeric (possibly missing ~ for float64 in constraint numeric)
./13_add_type_parameters.go:26:17: myComplex does not implement numeric (possibly missing ~ for complex128 in constraint numeric)

16. Aproximace datového typu

Řešení předchozího problému je v Go 1.18 řešeno novou syntaxí a sémantikou při deklaraci množiny datových typů. Pokud namísto:

type numeric interface {
        int | float64 | complex128
}

napíšeme:

type numeric interface {
        ~int | ~float64 | ~complex128
}

určujeme tímto zápisem, že numeric bude odpovídat nejenom explicitně zapsaným typům, ale i odvozeným typům, tedy například našemu typu myInt atd.

Můžeme si to ostatně velmi snadno otestovat:

package main
 
import "fmt"
 
type numeric interface {
        ~int | ~float64 | ~complex128
}
 
func add[T numeric](x T, y T) T {
        return x + y
}
 
type myInt int
 
type myFloat float64
 
type myComplex complex128
 
func main() {
        var x myInt = 42
        var y myFloat = 3.14
        var z myComplex = 1 + 2i
 
        fmt.Println(add(x, x))
        fmt.Println(add(y, y))
        fmt.Println(add(z, z))
}

Tento kód již bude bez problémů přeložitelný.

17. Překlad běžné funkce compare do assembleru

Prozatím jsme si ukázali, jakým způsobem lze s generickými funkcemi a generickými datovými typy pracovat na úrovni programovacího jazyka Go. Poměrně užitečné je však zjistit, jak se například přeloží generické funkce z Go do strojového kódu. Nejprve se ve stručnosti seznámíme s poněkud zvláštním zápisem kódu přeloženého do assembleru (assembler je pro Go specifický a odlišný například od GNU Assembleru, nicméně alespoň základní struktura by měla být poměrně dobře rozpoznatelná). Překládat budeme (prozatím) jednoduchou negenerickou funkcí compare:

package main
 
import "fmt"
 
func compare(x int, y int) bool {
        return x < y
}
 
func main() {
        fmt.Println(compare(1, 2))
}

Překlad do assembleru provedeme tímto příkazem:

$ go tool compile -S 06_comparable.go > 06_comparable.s

Výsledkem bude soubor nazvaný „06_comparable.s“, v němž lze nalézt i přeloženou funkci compare. Z výpisu by mělo být patrné, že funkce byla přeložena do tří instrukcí (poslední tři řádky) – porovnání parametrů, nastavení výstupního registru AL na základě výsledku porovnání a výskok (návrat) z funkce (resp. v žargonu assembleru návrat ze subrutiny):

"".compare STEXT nosplit size=7 args=0x10 locals=0x0 funcid=0x0 align=0x0
        0x0000 00000 (06_comparable.go:5)       TEXT    "".compare(SB), NOSPLIT|ABIInternal, $0-16
        0x0000 00000 (06_comparable.go:5)       FUNCDATA        $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0000 00000 (06_comparable.go:5)       FUNCDATA        $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0000 00000 (06_comparable.go:5)       FUNCDATA        $5, "".compare.arginfo1(SB)
        0x0000 00000 (06_comparable.go:5)       FUNCDATA        $6, "".compare.argliveinfo(SB)
        0x0000 00000 (06_comparable.go:5)       PCDATA  $3, $1
        0x0000 00000 (06_comparable.go:6)       CMPQ    BX, AX
        0x0003 00003 (06_comparable.go:6)       SETGT   AL
        0x0006 00006 (06_comparable.go:6)       RET

18. Překlad funkce s typovými parametry do assembleru

Nyní se ovšem podívejme na to, jakým způsobem bude přeložena generická varianta funkce compare, konkrétně varianta akceptující tři různé typy parametrů:

package main
 
import "fmt"
 
type comparable interface {
        int | float64 | string
}
 
func compare[T comparable](x T, y T) bool {
        return x < y
}
 
func main() {
        fmt.Println(compare(1, 2))
        fmt.Println(compare(1.5, 2.6))
        fmt.Println(compare("foo", "bar"))
}

Opět provedeme překlad:

Tip do článku - TOP100

$ go tool compile -S 08_compare_type_parameters.go > 08_compare_type_parameters.s

Ve výsledném souboru „08_compare_type_parameters.s“ nyní nalezneme nikoli jedinou přeloženou funkci compare, ale hned tři varianty. Strojový kód prvních dvou variant je dobře čitelný, u varianty třetí je to sice složitější, ale minimálně volání funkce runtime.cmpstring (která lexikograficky porovná řetězce) je možné rozeznat:

"".compare[go.shape.int_0] STEXT dupok nosplit size=7 args=0x18 locals=0x0 funcid=0x0 align=0x0
        0x0000 00000 (08_compare_type_parameters.go:9)  TEXT    "".compare[go.shape.int_0](SB), DUPOK|NOSPLIT|ABIInternal, $0-24
        0x0000 00000 (08_compare_type_parameters.go:9)  FUNCDATA        $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0000 00000 (08_compare_type_parameters.go:9)  FUNCDATA        $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0000 00000 (08_compare_type_parameters.go:9)  FUNCDATA        $5, "".compare[go.shape.int_0].arginfo1(SB)
        0x0000 00000 (08_compare_type_parameters.go:9)  FUNCDATA        $6, "".compare[go.shape.int_0].argliveinfo(SB)
        0x0000 00000 (08_compare_type_parameters.go:9)  PCDATA  $3, $1
        0x0000 00000 (08_compare_type_parameters.go:10) CMPQ    CX, BX
        0x0003 00003 (08_compare_type_parameters.go:10) SETGT   AL
        0x0006 00006 (08_compare_type_parameters.go:10) RET
        0x0000 48 39 d9 0f 9f c0 c3                             H9.....
 
"".compare[go.shape.float64_0] STEXT dupok nosplit size=8 args=0x18 locals=0x0 funcid=0x0 align=0x0
        0x0000 00000 (08_compare_type_parameters.go:9)  TEXT    "".compare[go.shape.float64_0](SB), DUPOK|NOSPLIT|ABIInternal, $0-24
        0x0000 00000 (08_compare_type_parameters.go:9)  FUNCDATA        $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0000 00000 (08_compare_type_parameters.go:9)  FUNCDATA        $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0000 00000 (08_compare_type_parameters.go:9)  FUNCDATA        $5, "".compare[go.shape.float64_0].arginfo1(SB)
        0x0000 00000 (08_compare_type_parameters.go:9)  FUNCDATA        $6, "".compare[go.shape.float64_0].argliveinfo(SB)
        0x0000 00000 (08_compare_type_parameters.go:9)  PCDATA  $3, $1
        0x0000 00000 (08_compare_type_parameters.go:10) UCOMISD X0, X1
        0x0004 00004 (08_compare_type_parameters.go:10) SETHI   AL
        0x0007 00007 (08_compare_type_parameters.go:10) RET
        0x0000 66 0f 2e c8 0f 97 c0 c3                          f.......
 
"".compare[go.shape.string_0] STEXT dupok size=120 args=0x28 locals=0x28 funcid=0x0 align=0x0
        0x0000 00000 (08_compare_type_parameters.go:9)  TEXT    "".compare[go.shape.string_0](SB), DUPOK|ABIInternal, $40-40
        0x0000 00000 (08_compare_type_parameters.go:9)  CMPQ    SP, 16(R14)
        0x0004 00004 (08_compare_type_parameters.go:9)  PCDATA  $0, $-2
        0x0004 00004 (08_compare_type_parameters.go:9)  JLS     63
        0x0006 00006 (08_compare_type_parameters.go:9)  PCDATA  $0, $-1
        0x0006 00006 (08_compare_type_parameters.go:9)  SUBQ    $40, SP
        0x000a 00010 (08_compare_type_parameters.go:9)  MOVQ    BP, 32(SP)
        0x000f 00015 (08_compare_type_parameters.go:9)  LEAQ    32(SP), BP
        0x0014 00020 (08_compare_type_parameters.go:9)  MOVQ    BX, "".x+56(FP)
        0x0019 00025 (08_compare_type_parameters.go:9)  MOVQ    DI, "".y+72(FP)
        0x001e 00030 (08_compare_type_parameters.go:9)  FUNCDATA        $0, gclocals·7a680c56c7799a8f60d071b2f2541840(SB)
        0x001e 00030 (08_compare_type_parameters.go:9)  FUNCDATA        $1, gclocals·69c1753bd5f81501d95132d08af04464(SB)
        0x001e 00030 (08_compare_type_parameters.go:9)  FUNCDATA        $5, "".compare[go.shape.string_0].arginfo1(SB)
        0x001e 00030 (08_compare_type_parameters.go:9)  FUNCDATA        $6, "".compare[go.shape.string_0].argliveinfo(SB)
        0x001e 00030 (08_compare_type_parameters.go:9)  PCDATA  $3, $1
        0x001e 00030 (08_compare_type_parameters.go:10) MOVQ    BX, AX
        0x0021 00033 (08_compare_type_parameters.go:10) MOVQ    CX, BX
        0x0024 00036 (08_compare_type_parameters.go:10) MOVQ    DI, CX
        0x0027 00039 (08_compare_type_parameters.go:10) MOVQ    SI, DI
        0x002a 00042 (08_compare_type_parameters.go:10) PCDATA  $1, $1
        0x002a 00042 (08_compare_type_parameters.go:10) CALL    runtime.cmpstring(SB)
        0x002f 00047 (08_compare_type_parameters.go:10) TESTQ   AX, AX
        0x0032 00050 (08_compare_type_parameters.go:10) SETLT   AL
        0x0035 00053 (08_compare_type_parameters.go:10) MOVQ    32(SP), BP
        0x003a 00058 (08_compare_type_parameters.go:10) ADDQ    $40, SP
        0x003e 00062 (08_compare_type_parameters.go:10) RET
        0x003f 00063 (08_compare_type_parameters.go:10) NOP
        0x003f 00063 (08_compare_type_parameters.go:9)  PCDATA  $1, $-1
        0x003f 00063 (08_compare_type_parameters.go:9)  PCDATA  $0, $-2
        0x003f 00063 (08_compare_type_parameters.go:9)  MOVQ    AX, 8(SP)
        0x0044 00068 (08_compare_type_parameters.go:9)  MOVQ    BX, 16(SP)
        0x0049 00073 (08_compare_type_parameters.go:9)  MOVQ    CX, 24(SP)
        0x004e 00078 (08_compare_type_parameters.go:9)  MOVQ    DI, 32(SP)
        0x0053 00083 (08_compare_type_parameters.go:9)  MOVQ    SI, 40(SP)
        0x0058 00088 (08_compare_type_parameters.go:9)  CALL    runtime.morestack_noctxt(SB)
        0x005d 00093 (08_compare_type_parameters.go:9)  MOVQ    8(SP), AX
        0x0062 00098 (08_compare_type_parameters.go:9)  MOVQ    16(SP), BX
        0x0067 00103 (08_compare_type_parameters.go:9)  MOVQ    24(SP), CX
        0x006c 00108 (08_compare_type_parameters.go:9)  MOVQ    32(SP), DI
        0x0071 00113 (08_compare_type_parameters.go:9)  MOVQ    40(SP), SI
        0x0076 00118 (08_compare_type_parameters.go:9)  PCDATA  $0, $-1
        0x0076 00118 (08_compare_type_parameters.go:9)  JMP     0
Poznámka: používání generických funkcí je sice elegantní, ale může vést k mnohdy významnému nárůstu velikosti výsledného strojového kódu se všemi z toho plynoucími důsledky – větší tlak na L1 cache atd.

19. Repositář s demonstračními příklady

Zdrojové kódy všech dnes použitých demonstračních příkladů byly uloženy do Git repositáře, který je dostupný na adrese https://github.com/tisnik/go-root (stále na GitHubu :-). V případě, že nebudete chtít klonovat celý repositář (ten je ovšem – alespoň prozatím – velmi malý, dnes má přibližně stovku kilobajtů), můžete namísto toho použít odkazy na jednotlivé demonstrační příklady, které naleznete v následující tabulce:

# Příklad/soubor Stručný popis Cesta
1 01_print.go funkce s konkrétními datovými typy https://github.com/tisnik/go-root/blob/master/article88/01_prin­t.go
2 02_print_overload.go pokus o přetížení funkce https://github.com/tisnik/go-root/blob/master/article88/02_prin­t_overload.go
3 03_print_no_conversion.go konverze datových typů není automatická https://github.com/tisnik/go-root/blob/master/article88/03_prin­t_no_conversion.go
4 04_print_interface.go použití prázdných rozhraní, které splňuje jakýkoli datový typ https://github.com/tisnik/go-root/blob/master/article88/04_prin­t_interface.go
5 05_generic_print.go využití typových parametrů funkce https://github.com/tisnik/go-root/blob/master/article88/05_ge­neric_print.go
6 06_type_parameter.go explicitní volání konkrétní varianty generické funkce https://github.com/tisnik/go-root/blob/master/article88/06_ty­pe_parameter.go
7 07_type_parameter_check.go kontrola typů parametrů volané funkce https://github.com/tisnik/go-root/blob/master/article88/07_ty­pe_parameter_check.go
8 08_comparable.go triviální porovnání dvou hodnot typu int https://github.com/tisnik/go-root/blob/master/article88/08_com­parable.go
9 09_comparable_variable_types.go sada funkcí pro porovnání dvojice hodnot různých typů https://github.com/tisnik/go-root/blob/master/article88/09_com­parable_variable_types.go
10 10_compare_type_parameters.go jediná funkce pro porovnání dvojice hodnot různých typů https://github.com/tisnik/go-root/blob/master/article88/10_com­pare_type_parameters.go
11 11_add_int.go datový systém jazyka Go a přetížené operátory: součet dvou hodnot https://github.com/tisnik/go-root/blob/master/article88/11_ad­d_int.go
12 12_add_type_parameters.go sada funkcí pro porovnání dvojice hodnot různých typů https://github.com/tisnik/go-root/blob/master/article88/12_ad­d_type_parameters.go
13 13_add_type_parameters.go jediná funkce pro porovnání dvojice hodnot různých typů https://github.com/tisnik/go-root/blob/master/article88/13_ad­d_type_parameters.go
14 14_add_type_parameters.go https://github.com/tisnik/go-root/blob/master/article88/12_ad­d_type_parameters.go

20. Odkazy na Internetu

  1. The Go Programming Language Specification
    https://go.dev/ref/spec
  2. Generics in Go
    https://bitfieldconsultin­g.com/golang/generics
  3. Tutorial: Getting started with generics
    https://go.dev/doc/tutorial/generics
  4. Type parameters in Go
    https://bitfieldconsultin­g.com/golang/type-parameters
  5. Go Data Structures: Binary Search Tree
    https://flaviocopes.com/golang-data-structure-binary-search-tree/
  6. Gobs of data
    https://blog.golang.org/gobs-of-data
  7. How the Go runtime implements maps efficiently (without generics)
    https://dave.cheney.net/2018/05/29/how-the-go-runtime-implements-maps-efficiently-without-generics
  8. Go 1.18 Release Notes
    https://golang.org/doc/go1.18
  9. Go 1.17 Release Notes
    https://golang.org/doc/go1.17
  10. Go 1.16 Release Notes
    https://golang.org/doc/go1.16
  11. Go 1.15 Release Notes
    https://golang.org/doc/go1.15
  12. Go 1.14 Release Notes
    https://golang.org/doc/go1.14
  13. Go 1.13 Release Notes
    https://golang.org/doc/go1.13
  14. Go 1.12 Release Notes
    https://golang.org/doc/go1.12
  15. Go 1.11 Release Notes
    https://golang.org/doc/go1.11
  16. Go 1.11 Release Notes
    https://golang.org/doc/go1.11
  17. Go 1.10 Release Notes
    https://golang.org/doc/go1.10
  18. Go 1.9 Release Notes
    https://golang.org/doc/go1.9
  19. Go 1.8 Release Notes
    https://golang.org/doc/go1.8
  20. A Proposal for Adding Generics to Go
    https://go.dev/blog/generics-proposal
  21. Proposal: Go should have generics
    https://github.com/golang/pro­posal/blob/master/design/15292-generics.md
  22. Know Go: Generics (Kniha)
    https://bitfieldconsultin­g.com/books/generics
  23. Balíček constraints
    https://pkg.go.dev/golang­.org/x/exp/constraints
  24. What are the libraries/tools you missed from other programming languages in Golang?
    https://www.quora.com/What-are-the-libraries-tools-you-missed-from-other-programming-languages-in-Golang?share=1
  25. Golang Has Generics—Why I Don't Miss Generics Anymore
    https://blog.jonathanoliver.com/golang-has-generics/
  26. Go 1.18 Generics based slice package
    https://golangexample.com/go-1–18-generics-based-slice-package/
  27. The missing slice package
    https://github.com/ssoroka/slice