Geneerisyyttä Golangiin – osa 2

Viime blogijulkaisussa tarkastelimme kahta perustavanlaatuista työkalua, jotka Go-kieli tarjoaa geneerisen eli yleisluontoisen koodin kirjoittamiseen: rajapintoja ja upottamista. Havaitsimme myös, että Gon rajapinnoilla on rajansa. Ne eivät korvaa täysin muiden kielten geneerisiä tyyppiparametrejä esimerkiksi tilanteessa, jossa funktion paluuarvon tyypin halutaan riippuvan argumentin tyypistä. 

Esimerkiksi seuraavanlaisen TypeScript-funktion kirjoittaminen Golla ei onnistu suoraviivaisesti:

const map = <T1, T2>(elems: T1[], f: (elem: T1) => T2) => {
      const out: T2[] = []
      for (const elem of elems) {
             out.push(f(elem))
      }
      return out
}

Tämä johtaa kiusalliseen tilanteeseen, jossa saatamme joutua kirjoittamaan koodiltaan täsmälleen identtisiä funktioita mutta eri tyypeille. Tässä osassa tutkimme apuvälineitä ja tapoja, miten voimme refaktoroida koodia välttääksemme tällaiset tilanteet. Aloitetaan viime blogitekstissäkin sivutuista tyyppiassertioista ja niiden läheisestä kumppanista, tyyppivivuista.

Tyypit tarkasteluun

Yksinkertainen työkalu Gossa tuntematonta tyyppiä edustavan muuttujan tarkasteluun on tyyppiassertio. Sen avulla voidaan selvittää, edustaako esimerkiksi interface{}-tyyppisen muuttuja arvo jotain tiettyä, tarkemmin määriteltyä tyyppiä.

t := i.(T)

tai 

t, ok := i.(T)

Ensimmäisessä tapauksessa Go panikoi eli tuottaa poikkeuksen, jos muuttujan todellinen tyyppi ei ole assertion mukainen. Toisessa tapauksessa bool-tyyppinen paluuarvo ok kertoo, vastasiko tyyppi asetettua odotusta.

Ensimmäinen tapaus muistuttaa hyvin paljon ajonaikaista tyyppimuunnosta muista kielistä. Kyseessä ei ole kuitenkaan täsmälleen sama asia, sillä Gossa on olemassa myös erikseen tyyppimuunnos (type conversion). Sitä käytetään kielessä lähinnä vain primitiivisten tyyppien muuntamiseen tyypistä toiseen esimerkiksi eri int-tyyppien välillä. 

Tyyppiassertio sen sijaan vain tarkistaa, sopiiko annetun muuttujan arvo annettuun tyyppiin ja ettei se ole myöskään nil. Paluuarvo t on sama arvo kuin i, mutta nyt kääntäjä kohtelee sitä tyypin T muuttujana.

Tyyppiassertiot sopivat parhaiten tapaukseen, jossa meillä on tiedossa rajattu joukko tyyppejä, joita annettu arvo voi edustaa. Assertion pohjalta voimme sitten suorittaa toimenpiteet kullekin tyypille erikseen.

Esimerkiksi Gon standardikirjaston net/http-paketin Client käyttää CloseIdleConnections-metodissaan tyyppiassertiota tarkistamaan, löytyykö funktiokutsun paluuarvolta haluttu metodi (https://golang.org/src/net/http/client.go):  

func (c *Client) CloseIdleConnections() {
      type closeIdler interface {
            CloseIdleConnections()
      }
      if tr, ok := c.transport().(closeIdler); ok {
            tr.CloseIdleConnections()
      }
}

Yllä siis CloseIdleConnections kutsuu Clientin toista metodia nimeltä transport. Sen jälkeen se varmistaa tyyppiassertiolla, että transportin paluuarvo on tyyppiä closeIdler. Tässä closeIdler on määritelty näppärästi CloseIdleConnectionsin sisällä rajapintana, jolla on vain yksi metodi, nimeltään niin ikään CloseIdleConnections. 

Jos tyyppiassertio onnistuu, voidaan tuloksena saatavalle tr-muuttujalle turvallisesti kutsua CloseIdleConnections-metodia, koska closeIdler-tyyppisenä muuttujana sillä täytyy olla olemassa tuo metodi. Muuten metodia ei kutsuta.

Tyyppiassertiota käytetään ylipäänsä tässä kohden, koska transport-metodi palauttaa RoundTripper-tyyppisen arvon:

func (c *Client) transport() RoundTripper { /*...*/ }

RoundTripper on puolestaan rajapinta, jolla on vain yksi metodi: RoundTrip. 

type RoundTripper interface {
      RoundTrip(*Request) (*Response, error)
}

Ilman tyyppiassertiota ei siis voida sanoa, onko transportin paluuarvolla CloseIdleConnections-metodia vai ei! Tämä saadaan selville ainoastaan, kun paluuarvolle tehdään assertio tyypin closeIdler kanssa. Sitä ennen tiedetään varmasti vain, että sillä on ainakin RoundTrip-metodi, muttei välttämättä mitään muuta.

Useimmiten tyyppiassertiota kannattaa käyttää, kun voidaan mielekkäästi käsitellä tapaus, jolloin muuttuja ei olekaan testattavaa tyyppiä. Joskus tulee kuitenkin tarve käyttää tyyppiassertiota puhtaasti tyyppimuunnoksen kaltaisesti eli panikoida, kun assertio epäonnistuu.

Yleinen tapa on esimerkiksi web-palvelinohjelmoinnissa kuljettaa middlewareilta arvoja varsinaisille HTTP-pyynnön käsittelijäfunktioille varastoimalla ne http.Requestin kontekstiin.  Esimerkiksi go-chi-kirjasto (https://github.com/go-chi/chi) tarjoaa WithValue-middlewaren, jolla voidaan välittää mielivaltainen arvo eteenpäin HTTP-pyynnön käsittelijäketjussa:

func WithValue(key interface{}, val interface{}, 
      ) func(next http.Handler) http.Handler {
      return func(next http.Handler) http.Handler {
            fn := func(w http.ResponseWriter, r *http.Request) {
                  r = r.WithContext(context.WithValue(r.Context(), 
                        key, val))
                  next.ServeHTTP(w, r)
            }
            return http.HandlerFunc(fn)
      }
}

Ongelma syntyy kuitenkin myöhemmin arvoa luettaessa, sillä lukemisoperaatio r.Context().Value(key) palauttaa interface{}-tyyppisen arvon. Jotta tätä arvoa voidaan hyödyntää mitenkään mielekkäästi, täytyy sille tehdä tyyppiassertio. 

Tässä tapauksessa, jos assertio epäonnistuu, se tarkoittaa kuitenkin yleensä bugia ja tilannetta, mihin ei pitäisi koskaan päätyä. Kontekstiin varastoitavien arvojen tyypin pitäisi nimittäin olla koodarin tiedossa, joten todennäköisesti joko alkuperäisen arvon paikalle tai assertioon on lipsahtanut väärä tyyppi. Siksi on järkevämpää olla tarkistamatta assertion onnistumista ja antaa Gon panikoida virheen sattuessa:

val := req.Context().Value(key).(CustomType)

Kääntöpuolena tässä on tietysti se, ettei virhettä voida havaita kuin ajonaikaisesti. Riskiä voidaan lieventää rajoittamalla tietyn avaintyypin lukeminen ja kirjoittaminen molemmat omiin yksittäisiin funktioihinsa ja tekemällä niille luotettavat yksikkötestit. Vahvoja argumentteja on kuitenkin esitetty sen puolesta, miksi Contextia ei pitäisi hyödyntää arvojen varastoimiseen, sekä tarjottu kiertoteitä sen ehkäisemiseksi:

https://www.calhoun.io/pitfalls-of-context-values-and-how-to-avoid-or-mitigate-them/

https://faiface.github.io/post/context-should-go-away-go2/

Kun muuttujan tyyppiä halutaan vertailla useamman tyypin kesken, hyödyllinen työkalu on tyyppivipurakenne (type switch), joka toimii täsmälleen kuin switch-case, mutta tyypeille. Alla on esitetty tyyppivivun käytöstä esimerkki, jossa päätetään oikea HTTP-status-koodi sen pohjalta, minkä tyyppinen virhe SendHTTPError-funktioon on saapunut:

func SendHTTPError(err error, writer http.ResponseWriter) {
      var status int
      switch err.(type) {
      case *ResourceNotFound:
            status = http.StatusNotFound
      case *BadRequest:
            status = http.StatusBadRequest
      case *Forbidden:
            status = http.StatusForbidden
      case *MIMETypeNotFound:
            status = http.StatusUnsupportedMediaType
      case *Unauthorized:
            status = http.StatusUnauthorized
      default:
            status = http.StatusInternalServerError
      }
      writer.WriteHeader(status)
}

Tyyppiassertioiden ja tyyppivipujen rajat tulevat tietysti vastaan siinä, kun tarkasteltavien tyyppien joukko ei olekaan kätevästi rajattu tai tiedossa. Esimerkiksi aikaisempi map-ongelmamme ei niiden avulla ratkea. 

Standardikirjasto apuun

Lisää inspiraatiota geneerisyyden tavoittelemiseen voimme hakea Gon standardikirjastosta. Tutkitaan esimerkiksi, miten Go toteuttaa järjestämisalgoritmit. Eihän yleisluontoisia sort-funktioita pidä sentään joutua kirjoittamaan jokaiselle tietotyypille erikseen?

Gon sort-paketin tarjoaman Sort-funktion annotaatio näyttää seuraavalta:

func Sort(data Interface)

Katsotaan tarkemmin data-parametrin tyyppiä Interface:

type Interface interface {
      // Len is the number of elements in the collection.
      Len() int
      // Less reports whether the element with
      // index i should sort before the element with index j.
      Less(i, j int) bool
      // Swap swaps the elements with indexes i and j.
      Swap(i, j int)
}

Tästä huomaamme, että parametrilta data vaaditaan, että se toteuttaa Len-, Less- ja Swap-metodit datan käsittelyyn. Nämä metodit käsittelevät dataa ainoastaan sen pituuden ja sen jäsenten indeksien kautta. Toisena asiana havaitaan, että Sort muokkaa annettua dataa paikallaan eli mutatoi sen sisältöä palauttamatta mitään arvoa. Tämän ansiosta Sort-funktion ei tarvitse tietää datan tyypistä muuta kuin sen, että se toteuttaa Interface-rajapinnan.

Tästä seuraa luonnollisesti se rajoitus, ettei Sort-funktio voi järjestää muunlaista dataa kuin tämän rajapinnan toteuttavia tyyppejä. Tämä voi kuitenkin kuulostaa tiukemmalta rajoitukselta kuin se onkaan, sillä upottamisen ansiosta voimme kääriä helposti minkä tahansa järjestämistä kaipaavan tietotyypin uuteen tietueeseen, joka toteuttaa nämä metodit.

type SortableDataType struct {
      MyDataType
}

func (s SortableDataType) Len() int { /*...*/ }

func (s SortableDataType) Less(i, j int) bool { /*...*/ }

func (s SortableDataType) Swap(i, j int) { /*...*/ }

Tutkitaan, miten voisimme hyödyntää tämän tyyppistä ratkaisua map-funktiomme tapaukseen. Jos vaihdamme ratkaisumme mutatoivaksi, funktiollemme annettavat elementit täytyy tällöin refaktoroida tietysti sellaisiksi, että niitä voi mielekkäästi mutatoida. 

Luodaan tätä varten uusi aputyyppi nimeltä Integer. Toteutetaan tälle tyypille metodi-nimeltä Mutate. joka hoitaa kokonaisluvun inkrementoinnin. Sovitaan lisäksi, että tätä tyyppiä käsitellään pointterina, jottei muuttujaa tulla vahingossa kopioineeksi. Mutaten aikaansaama muutos ei nimittäin näkyisi kopiossa.

type Integer struct {
      value int
}

func (n *Integer) Mutate() {
      n.value++
}

Luodaan lisäksi kaksi rajapintaa: Mutable mutatoituville arvoille sekä MutableCollection kokoelmalle mutatoituvia arvoja:

type Mutable interface {
      Mutate()
}

type MutableCollection interface {
      Len() int
      At(i int) Mutable
}

Toteutetaan rajapinta MutableCollection tietueella nimeltä IntegerCollection:

type IntegerCollection struct {
      slice []*Integer
}

func (c IntegerCollection) Len() int {
      return len(c.slice)
}

func (c IntegerCollection) At(i int) Mutable {
      return c.slice[i]
}

Lopulta toteutetaan varsinainen funktiomme. Annetaan sille nimeksi forEach:

func forEach(coll MutableCollection) {
      for i := 0; i < coll.Len(); i++ {
            coll.At(i).Mutate()
      }
}


func main() {
      ints := []*Integer{&Integer{1}, &Integer{2}, &Integer{3}}
      forEach(IntegerCollection{ints})
      // ints: []*Integer{&Integer{2}, &Integer{3}, &Integer{4}}
}

 

Näin koodimme toimii täysin geneerisesti ilman yhtäkään tyyppiassertiota eikä paluuarvokaan tuota päänvaivaa. Tämä ratkaisu on tietysti täysin yliampuva yksinkertaisen for-silmukan tapauksessa, sillä ylivoimaisessa valtaosassa tapauksista silmukka kannattaa vain kirjoittaa auki koodiin. Jos tarvitsemme alkuperäistä nums-tietorakennetta johonkin, se pitää ennen forEach-kutsua kopioida talteen. Toisaalta alkuperäistä tietorakennetta mutatoidessa säästetään kopioinnin vaiva. 

Hyvä on huomata, että len-funktion kutsuminen ei tuota ylimääräistä iteraatiokierrosta. Go pitää nimittäin lokaalien leikkausmuuttujien pituuden automaattisesti muistissa.

Nyt joutuisimme aina kuitenkin foreach-funktiota käytettäessä käärimään iteroitavat arvot Mutable-rajapinnan toteuttavaan tietuetyyppiin ja lisäksi koodaamaan niille MutableCollectionin toteuttavan Collection-tietuetyypin. Yksinkertaisten algoritmien yleistämiseen rajapinta-argumentti ei ole siis kovinkaan käytännöllinen temppu. Jos meidän sitä vastoin tarvitsisi toteuttaa jokin paljon monimutkaisempi operaatio mielivaltaiselle joukolle tyyppejä (kuten optimoitu lajittelualgoritmi), lähestymistapamme olisi jo huomattavasti järkevämpi.

Sulkeuman eleganssi

On kuitenkin olemassa vieläkin elegantimpi ratkaisu, johon törmäämme, kun tutkimme sort-paketin Slice-funktiota:

func Slice(slice interface{}, less func(i, j int) bool)

Esimerkki funktion käytöstä on seuraavanlainen:

func main() {
      people := []struct {
         Name string
         Age  int
      }{
         {"Gopher", 7},
         {"Alice", 55},
         {"Vera", 24},
         {"Bob", 75},
      }
      sort.Slice(people, func(i, j int) bool
            return people[i].Name < people[j].Name 
      })
}

Tässä standardikirjasto käyttää hyödyksi sekä mutaatioita että sulkeumaa (closure). Argumenttina annettavan anonyymin vertailufunktion sisällä on nimittäin tiedossa people-muuttujan tyyppi, koska se on  tämän muuttujan samassa näkyvyysalueessa (scope). Vertailufunktio kantaa tätä tyyppitietoa myös Slice-funktion sisälle, jossa sitä varsinaisesti kutsutaan. Siten Slice-funktion itsessään ei tarvitse tietää people-muuttujasta mitään.

Huomataan myös, että Slicen ensimmäinen argumentti on tyyppiä interface{}. Tässä on etuna se, että tämän tyyppinen arvo hyväksy minkä tahansa leikkaustyypin toisin kuin []interface{}. Kuten havaittua, esimerkiksi []int-tyyppistä arvoa ei voisi laittaa []interface{}-parametrin paikalle mutta interface{}-parametrin paikalle voi. Kääntöpuolena funktio panikoi, jos ensimmäinen argumentti ei ole leikkaus, joten käytännössä se menettää staattisen tyypityksen edut.

Kokeillaan samaa ratkaisua oman for-silmukkamme tapaukseen. Jotta pääsemme eroon panikoinnin mahdollisuudesta, välitetäänkin funktiolle vain tietorakenteen pituus:

func forEach(length int, f func(i int)) {
      for i := 0; i < length; i++ {
            f(i)
      }
}

Ja varsinainen funktion kutsuminen:

func main() {
      ints := []int{1, 2, 3}
      forEach(len(ints), func(i int) { ints[i]++ })
      // ints: []int{2, 3, 4}
}

Tämä on jo hyvin elegantti tapa ratkaista ongelmamme. Ainoana pulmallisena puolena joudumme muokkaamaan alkuperäistä listaa. Lisäksi tietysti sulkeuman käytön kääntöpuolena on se, että jos iteroitava tietorakenne ei ole samassa näkyvyysalueessa funktioargumentin määrittelyn kanssa, joudutaan funktiossa tekemään tyyppiassertio. Tämä johtuu siitä, ettei tällöin tietorakenteen elementtien tyyppi ole tiedossa.

Läheskään jokainen geneerisyyteen liittyvä ongelma ei ratkea sulkeumalla yhtä näppärästi. Tässä tapauksessa kuitenkin, kun itse tietorakenteen koko ei muutu, se tekee toteutuksesta hyvin suoraviivaisen. Kahden eri tyypin tapauksessa joutuisimme ulkoistamaan transformaation tuloksen kirjoittamisen anonyymille funktiolle, mikä on hieman kömpelöä:

func main() {
      ints := []int{1, 2, 3}
      strs := make([]string, len(ints))
      forEach(len(ints), func(i int) { 
            strs[i] = strconv.Itoa(ints[i])
      })
      // strs: []string{"1", "2", "3"}
}

Voimme kuitenkin yhdistellä sulkeuman käyttöä aiemman rajapinta-argumenttia hyödyntäneen ratkaisun kanssa. Näin voitaisiin saada aikaan esimerkiksi geneerinen, tyyppiturvallinen suodatuslogiikka:

type Mover interface {
      Len() int
      Move(i int)
}

type IntMover struct {
      src []int
      dst []int
}

func (s *IntMover) Move(i int) {
      s.dst = append(s.dst, s.src[i])
}

func (s *IntMover) Len() int {
      return len(s.src) 
}

func filter(mover Mover, f func(i int) bool) {
      for i := 0; i < mover.Len(); i++ {
            if f(i) {
                  mover.Move(i)
            }
      }
}

func main() {
      mover := &IntMover{src: []int{1, 2, 3, 4, 5}}
      filter(mover, func(i int) bool {
            return mover.src[i] % 2 == 0
      })
      // mover.dst: []int{2, 4}
}

Viimeiset huomiot

Kysymys voi tietysti tässä vaiheessa herätä, onko refaktorointi siisteistä, paluuarvoja palauttavista funktioista sivuvaikutuksellisiin funktioihin fiksua. Erityisesti funktionaaliseen ohjelmointiin tottuneille tämä voi tuntua likaiselta. Loppujen lopuksi järkevällä suunnittelulla vältetään kuitenkin suurimmat ongelmat. 

Esimerkiksi web-palvelinohjelmoinnissa voimme välittää aina datamuunnoksien- ja operaatioiden seuraavan vaiheen alemmalle tietotyyppikerrokselle, kunnes saavutetaan niiden varsinainen sivuvaikutuksellinen päätepiste, kuten tietokantaan kirjoitus tai HTTP-vastauksen lähettäminen. Tällöin ylemmän kerroksen tarvitsee välittää varsin vähän siitä, millaisia sivuvaikutuksia alempi kerros tuottaa. Seurauksena syntyy hyvin testattavaa ja selkeää koodia.

Lisäksi, jos operaatioiden tyypit ovat tiedossa tai hyvin rajatut, voimme aina palata paluuarvoihin joko konkreettisten tyyppien tai rajapintojen kera. Meidän tapauksessamme siis esimerkiksi:

func mapInts(elems []int, f func(int) int) []int {
      var ret []int
      for _, elem := range elems {
            ret = append(ret, f(elem))

      return ret
}

Tai geneerisemmässä mutta silti rajoitetussa tapauksessa:

func (elems []MyIface, f func(MyIface) AnotherIface)
      []AnotherIface {
      var ret []AnotherIface
      for _, elem := range elems {
            ret = append(ret, f(elem))
      }
      return ret
}

tai

type MyIface {
      DoStuff() AnotherIface
}

func (elems []MyIface) []AnotherIface {
      var ret []AnotherIface
      for _, elem := range elems {
            ret = append(ret, elem.DoStuff())


return ret

}

Geneeristen tyyppiparametrien tulo Golangiin on tällä hetkellä luonnoksen asteella Go 2:a varten. Gon tekijät ovat myös itse tiedostaneet, miten työkalujen puute geneerisen koodin luontiin luo kielelle rajoitteita ja pakottaa tietyissä tapauksissa saman koodin kirjoittamiseen moneen kertaan eri tyypeille. Toisaalta geneeristen rakenteiden negatiivisena puolena on se, että jokainen iso lisäys tuo kieleen monimutkaisuutta, jota koko sen suunnittelufilosofia pyrkii välttämään. 

Lisää kielen kehittäjien ajatuksista ja suunnitelmista tyyppiparametrien suhteen voi lukea seuraavista linkeistä:

https://blog.golang.org/generics-next-step

https://blog.golang.org/why-generics

https://go.googlesource.com/proposal/+/refs/heads/master/design/go2draft-type-parameters.md

Tämänhetkisen Go 2 -luonnossuunnitelman mukaisia tyyppiparametreja pääsee myös testaamaan toiminnassa:

https://go2goplay.golang.org/

Vastaa

Sähköpostiosoitettasi ei julkaista. Pakolliset kentät on merkitty *