Generics – generické programovanie

Thursday, Mar 10, 2022 by Lenka

Generické programovanie, tiež známe ako parametrický polymorfizmus v iných programovacích jazykoch, je spôsob ako oddeliť algoritmus od konkrétneho typu. Abstrahovaním typu umožňuje bežným zápisom funkcií podporovať viaceré, aj vopred neznáme typy argumentov funkcií a tým znižovať počet duplikácií funkcií.

Ako vlastnosť programovacieho jazyka Go neboli generické prvky obsiahnuté v prvej verzii (Go1.x) ani v dizajne jazyka. Autori odmietali kvôli komplikovanosti a spomaleniu kompilácie C++ “template metaprogramming” riešenie aj “type erasure” riešenie Javy kvôli problémom so zabúdaním typov a pretypovávaním. Po mnohých debatách medzi návrhmi a návrhovými dokumentmi medzi komunitou Go - a tvrdej práci vývojového tímu a komunity - sa podpora generík nasledujúca princípy Go postupne stáva realitou.

Autori jazyka museli zvážiť klady a zápory tejto funkcie v programovacom jazyku ako je Go, ktorého cieľom bol vývoj sieťového softvéru, a preto sa spočiatku rozhodli pre funkcie ako súbežnosť, škálovateľnosť, atď.

Dnes sa každá funkcia, ktorú v Go napíšeme, musí vzťahovať iba na konkrétny typ. Avšak s generikami by sme mohli byť schopní napísať funkciu sort(), ktorá funguje ako pre integer, tak aj float32, float64, complex či string bez toho, aby sme ich museli na základe typov výslovne písať. A to všetko so statickými kontrolami počas kompilácie.

Výhody

Generická dátová štruktúra a algoritmy poskytujú mnoho výhod, medzi ktoré patrí:

  • flexibilita
  • opätovné použitie kódu
  • používať predtým odladené, optimalizované a efektívne balíky alebo knižnice
  • väčšiu škálovateľnosť
  • ľahšiu manipuláciu s rastúcou základňou kódov

Už sme sa zoznámili so základmi jazyka Go, rozumieme typovému systému, vrátane priradenia hodnôt a pretypovania. Tak isto rozumieme interface-om a ich použitiu v Go programoch. V tejto súvislosti sa zameriame na:

  • Prehľad generického programovania - tu stručne zhrnieme históriu a motiváciu generík Go
  • Čo by pre Go znamenalo pridanie generík - tu si povieme, prečo by boli generiká pre Go užitočnou funkciou
  • Prečo sú generiká Go dôležité a prečo ich potrebujeme
  • Aké ďalšie možnosti boli k dispozícii pred generikami a prečo nebola podpora generík do jazyka pridaná skôr

Prehľad generického programovania

Generiká umožňujú funkciám alebo dátovým štruktúram prijímať niekoľko typov, ktoré sú vopred definované. Aby sme skutočne pochopili, čo to znamená, pozrime sa na veľmi jednoduchý prípad. Povedzme, že chceme vytvoriť funkciu, ktorá vezme jeden slice a vypíše jeho elementy.

func Print(s []string) {
	for _, v := range s {
		fmt.Print(v)
	}
}

Jednoduché, však? Čo ak chceme, aby slice obsahoval celé čísla? Bude potrebné vytvoriť novú funkciu:

func Print(s []int) {
	for _, v := range s {
		fmt.Print(v)
	}
}

Tieto riešenia sa môžu javiť ako zbytočné, pretože meníme iba parameter. Ale momentálne to tak v Go riešime. Generika nám však umožňuje deklarovať naše funkcie takto:

func Print[T any](s []T) {
	for _, v := range s {
		fmt.Print(v)
	}
}

V tejto funkcii deklarujeme dve veci: Máme T, čo je typ ľubovoľného kľúčového slova (toto kľúčové slovo je špecificky definované ako súčasť generika, ktoré označuje akýkoľvek typ). A náš parameter, kde máme premennú s, ktorej typ je slice T. Teraz budeme môcť našu metódu zavolať takto:

func main() {
	Print([]string{"Hello, ", "playground\n"})
	Print([]int{1,2,3})
}

Jedna metóda pre akýkoľvek typ premennej - nádhera, čo? Toto je len jedna z veľmi základných implementácií pre generiká. Ale zatiaľ to vyzerá dobre. Poďme preskúmať viac a uvidíme, kam až nás môžu generiká doviesť.

Funkcia, ktorá môže mať akýkoľvek druh parametra

Príklad, ktorý sme videli predtým, bol veľmi jednoduchý. Existujú obmedzenia, kam až nás môžu generiká doviesť. Napríklad Print je dosť jednoduchá funkcia, pretože Go dokáže vypísať akýkoľvek typ premennej.

Čo ak chceme robiť zložitejšie veci? Povedzme, že sme definovali vlastné metódy pre štruktúru a chceme ju zavolať:

package main

import (
	"fmt"
)

type worker string

func (w worker) Work(){
	fmt.Printf("%s is working\n", w)
}


func DoWork[T any](things []T) {
    for _, v := range things {
        v.Work()
    }
}

func main() {
	var a,b,c worker
	a = "A"
	b = "B"
	c = "C"
	DoWork([]worker{a,b,c})	
}

Program sa nám nepodarí spustiť, pretože slice spracovaný vo vnútri funkcie je typu any a neimplementuje metódu Work, kvôli ktorej sa nespustí.

type checking failed for main
prog.go2:25:11: v.Work undefined (type bound for T has no method Work)

V skutočnosti to však dokážeme zariadiť pomocou rozhrania:

type Person interface {
    Work()
}

func DoWork[T Person](things []T) {
    for _, v := range things {
        v.Work()
    }
}

Program funguje, však tento konkrétny príklad sme mohli napísať aj bez generík, len s interfacom takto:

package main

import (
	"fmt"
)

type Person interface {
    Work()
}

type worker string

func (w worker) Work(){
	fmt.Printf("%s is working\n", w)
}

func DoWorkInterface(things []Person) {
    for _, v := range things {
        v.Work()
    }
}

func main() {
	var d,e,f worker
	d = "D"
	e = "E"
	f = "F"
	DoWorkInterface([]Person{d,e,f})
}

Používanie generík pridá nášmu kódu iba dodatočnú logiku. Generiká sú stále vo fáze vývoja a majú svoje limity na vykonávanie zložitého spracovania.

Obmedzenie na základe množiny metód

Predpokladajme, že chceme napísať vlastnú verziu niečoho ako strings.Join, ktorý vezme slice T a vráti jeden string, ktorý ich spojí všetky dohromady. Vyskúšajme:

func Join[T any](things []T) (result string) {
    for _, v := range things {
        result += v.String()
    }
    return result
}

Vytvorili sme všeobecnú generickú funkciu Join(), ktorá pre ľubovoľný typ T berie parameter, ktorý je slicom T. Pri kompilácii narazíme na problém:

output := Join([]string{"a", "b", "c"})
// v.String undefined (type bound for T has no method String)

Ide o to, že vo vnútri funkcie Join() chceme zavolať .String() na každom prvku slicu tak, aby sa z neho stal reťazec. Ale Go musí byť schopný vopred skontrolovať, či typ T má metódu String(), a keďže nevie, čo je T, nemôže to urobiť.

Musíme mierne obmedziť typ T. Namiesto toho, aby sme prijali doslova akékoľvek T (any T), nás skutočne zaujímajú iba typy, ktoré majú metódu String(). Akýkoľvek takýto typ bude prijateľným vstupom do našej funkcie Join(). Tak ako vyjadríme toto obmedzenie v Go? Použijeme rozhranie:

type Stringer interface {
    String() string
}

Toto určuje, že daný typ má metódu String(). Takže teraz môžeme použiť toto obmedzenie na typ našej generickej funkcie:

func Join[T Stringer] ...

V predchádzajúcom príklade sme pomocou metódy String() obmedzili použitie typov. Čo ak nemôžeme využiť obmedzenie na základe množiny metód?

Porovnateľné typy

Ďalší zaujímavý príklad je funkcia Equal, ktorá vezme dva parametre typu T a vráti true ak sú rovné, v opačnom prípade vráti false.

func Equal[T any](a, b T) bool {
    return a == b
}

fmt.Println(Equal(1, 1))
// cannot compare a == b (operator == not defined for T)

Opäť narážame na problém. Teraz musíme obmedziť T iba na typy, ktoré pracujú s operátormi == alebo !=, Ktoré sú známe ako porovnateľné typy. Našťastie existuje jednoznačný spôsob, ako to určiť: namiesto any použijeme vstavané obmedzenie pre porovnanie comparable.

func Equal[T comparable] ...

Zoznam povolených typov

Poďme na ďalší príklad. Predpokladajme, že chceme urobiť niečo s hodnotami T, ktoré sa ani neporovnávajú, ani sa na ne nevyžadujú metódy. Napríklad, chceme napísať funkciu Max() pre všeobecný typ T, ktorý vezme slice T a vráti prvok s najvyššou hodnotou. Mohli by sme vyskúšať niečo také:

func Max[T any](input []T) (max T) {
    for _, v := range input {
        if v > max {
            max = v
        }
    }
    return max
}

Opäť narážame na problém pri kompilácii.

fmt.Println(Max([]int{1, 2, 3}))
// cannot compare v > max (operator > not defined for T)

Go nedokáže vopred overiť, že typ T bude pracovať s operátorom >. V Go slovníku povieme, že T je objednaný. Ako to môžeme napraviť? Mohli by sme jednoducho uviesť zoznam všetkých možných povolených typov v obmedzeniach, ako je tento (známy ako zoznam typov):

type Ordered interface {
    type int, int8, int16, int32, int64,
        uint, uint8, uint16, uint32, uint64, uintptr,
        float32, float64,
        string
}

Našťastie pre našu klávesnicu sú tieto a ďalšie užitočné obmedzenia už pre nás definované v balíku obmedzení constraint štandardnej knižnice, takže ich môžeme importovať a použiť takto:

func Max[T constraint.Ordered] ...

Teraz vieme, ako napísať funkcie, ktoré dokážu prijímať argumenty ľubovoľného typu. Čo však v prípade, že chceme vytvoriť typ, ktorý môže obsahovať akýkoľvek typ? Napríklad typ „slice všetkého“. Ukázalo sa, že je to veľmi jednoduché:

type Bunch[T any] []T

Týmto povieme, že pre akýkoľvek daný typ T je Bunch[T] slice hodnôt typu T. Napríklad Bunch[int] je slice integerov. Hodnoty tohto typu môžeme vytvárať bežným spôsobom:

x := Bunch[int]{1, 2, 3}

Ako by sme očakávali, môžeme písať generické funkcie, ktoré majú generické typy:

func PrintBunch[T any](b Bunch[T]) {...}

Tiež aj metódy:

func (b Bunch[T]) Print() {...}

Obmedzenia môžeme použiť aj na generické typy:

type StringableBunch[T Stringer] []T

Dôležitosť a užitočnosť generík Go - Prečo sú generiká v Go dôležité a prečo ich potrebujeme

Generické programovanie umožňuje reprezentáciu algoritmov a dátových štruktúr vo všeobecnej podobe s vylúčením konkrétnych prvkov kódu (napríklad typov).

Generics v Go tiež znamenajú štýl programovania, v ktorom sú typy abstrahované od definícií funkcií a dátových štruktúr. Ak ovládate Go celkom dobre, určite viete, že by sme mohli podobný výsledok dosiahnuť aj pomocou rozhraní, ale potom musíme napísať rovnaké metódy pre všetky typy. Ide o to, aby bolo možné vylúčiť, abstrahovať, typ prvku. Generiká nám umožnia napísať funkciu raz, napísať testy raz, spojiť ich do balíka a použiť ich kedykoľvek chceme. Nebude to pekné, keď dokážeme znova použiť efektívnu, plne odladenú implementáciu, ktorá funguje pre akýkoľvek typ?

Viete o tom, že už ste v Go používali generiká?

V štandardnej knižnici je už od začiatku Go niekoľko zabudovaných generických typov a funkcií. Napríklad len() a cap() akceptuje akýkoľvek konkrétny typ slice, či už vytvoríme []int, alebo []string. Ak by tomu tak nebolo, museli by sme pre každý slice konkrétneho typu definovať funkciu s iným názvom a typom, lenInt([]int), lenString([]string) a to by bolo veľmi škaredé.

Jazykové konštrukcie použité pred návrhom generických riešení Go

Aké možnosti môžeme použiť namiesto generík a prečo pôvodne nebola pridaná podpora generík?

package main

import (
	"fmt"
	"sort"
)

type Person struct {
	Name string
	Age  int
}

func (p Person) String() string {
	return fmt.Sprintf("%s: %d", p.Name, p.Age)
}

// ByAge implements sort.Interface for []Person based on the Age field.
type ByAge []Person

// Sort interface implementation:
// Len is the number of elements in the collection.
func (a ByAge) Len() int           { return len(a) }
// Swap swaps the elements with indexes i and j.
func (a ByAge) Swap(i, j int)      { a[i], a[j] = a[j], a[i] }
// Less reports whether the element with index i must sort before the element with index j.
func (a ByAge) Less(i, j int) bool { return a[i].Age < a[j].Age }

func main() {
	people := []Person{
		{"Bob", 31},
		{"John", 42},
		{"Michael", 17},
		{"Jenny", 26},
	}

	fmt.Println(people)
	// func Sort(data sort.Interface)
	// Sort sorts data. It makes one call to data.Len to determine n and O(n*log(n))
	// calls to data.Less and data.Swap. The sort is not guaranteed to be stable.
	sort.Sort(ByAge(people))
	fmt.Println(people)
}
[Bob: 31 John: 42 Michael: 17 Jenny: 26]
[Michael: 17 Jenny: 26 Bob: 31 John: 42]

Jedným z kľúčových rozlišovacích znakov v Go je prístup k rozhraniam, ktoré sú tiež zamerané na opätovné použitie kódu. Rozhrania konkrétne umožňujú písať abstraktné implementácie algoritmov.

Go už podporuje formu generického programovania pomocou prázdnych typov rozhraní interface{}. Napríklad môžeme napísať jednu funkciu, ktorá funguje pre rôzne typy slicov, pomocou prázdneho typu rozhrania a následne použiť reflexiu, zisťovať typ a pretypovávať, či použiť “type assert” s priradením typu a prepínačmi typov.

func Println(a ...interface{}) (n int, err error)

Prázdny typ rozhrania nám v podstate umožňuje zachytiť rôzne typy prostredníctvom prepínačov typov a priradením typov. Potom môžeme napísať funkcie, ktoré používajú tieto typy rozhraní, a tieto funkcie budú fungovať pre akýkoľvek typ, ktorý odovzdáme ako argument.

Táto metóda ale nepodporuje opätovné použitie kódu. Pri rozhraniach musíme písať prípady prepínania pre každý typ, ktorý chceme podporovať. Potom musíme použiť pretypovanie a prípady možností (switch, case), kedy kontrolujeme na základe typu, ktorou cestou kódu chceme pokračovať. Napríklad na obrátenie slicu ľubovoľného typu prvku môžeme odovzdať prázdne rozhranie ako parameter a s využitím priradenia typu môžeme získať typ, ktorý odovzdáme ako argumenty, keď funkciu zavoláme.

Aj keď sú prázdne rozhrania formou generík, nedávajú nám všetko, čo chceme, pretože náš kód nakoniec duplikujeme na viacerých miestach bez toho, aby sme tieto duplikáty skutočne odstránili.

Balík reflect nám tiež umožňuje napísať jedinú funkciu, ktorá dokáže vyprodukovať slice ľubovoľného daného typu.

Problém týchto spôsobov okrem jednoduchosti a prepoužiteľnosti aj chýbajúca statická kontrola typov pri kompilácii.

Záver

Vďaka generikám bude program Go bezpečnejší, efektívnejší na používanie a výkonnejší. To nám umožní implementovať mnoho problémov ako funkcie, ktoré by sa vzťahovali na rôzne typy. Upozorňujem, že to nie je zvláštne pre samotné Go, ale aj pre iné programovacie jazyky.

Predtým sme na napísanie funkcie, ktorá môže pracovať s dátovými typmi, ako sú slice a mapa, museli využívať typy rozhraní s pretypovaním.

Tento dizajn bol navrhnutý a prijatý ako zmena jazyka v budúcnosti. V súčasnosti očakávame, že táto zmena bude k dispozícii vo verzii Go 1.18 už tento mesiac.

Ak chcete experimentovať s príkladmi na generiká, playground go2goplay teraz podporuje generiká.

https://go2goplay.golang.org/

Koncom minulého roka vyšla Go 1.18 Beta. Beta verzia nám umožňuje nahliadnúť na hlavné features pred ich dokončením. Verzia Go 1.18 prinesie veľké zmeny, ktoré sa nezaobídu bez chýb a treba byť opatrný pri používaní Beta verzie. Môžeme tiež prispieť k ich vyladenia a preto, ak narazíte na nejaký bug, neváhajte a kontaktujte Golang tím pre rýchlu nápravu.

Pre viac informácií pozri https://go.dev/doc/tutorial/generics.

Zdroje:

  1. Lance, I., & Griesemer, T. R. (2021, August 20). Type Parameters Proposal. Type parameters proposal. https://go.googlesource.com/proposal/+/refs/heads/master/design/43651-type-parameters.md
  2. Go 1.18 release notes. Go. https://tip.golang.org/doc/go1.18

Vaše otázky, návrhy a komentáre

Verím, že vás tento návod inšpiroval a budeme vďační, ak nám dáte spätnú väzbu a pomôžete nám zamerať sa na to, čo by vás zaujímalo.

TAK ČO HOVORÍŠ ?

Kontaktuj nás ak potrebuješ pomoc, veľmi radi pomôžeme.

Kontaktuj nás