Geneerisyyttä Golangiin – osa 1

Viime aikoina olen päässyt tutustumaan asiakasprojektissa Googlen Golang- eli lyhyesti Go-ohjelmointikieleen. Go on staattisesti tyypitetty C-tyylinen kieli, jossa – toisin kuin C:ssä – on kuitenkin muun muassa turvallinen muistinhallinta, roskien keruu, tuki rajapinnoille ja sisäänrakennetut primitiivit rinnakkaisuudelle.

Kielen suunnittelufilosofiaan kuuluu se, ettei siihen ole sisällytetty mitään ylimääräistä. Seurauksena siitä on jätetty pois myös monia ominaisuuksia, joita muiden ohjelmointikielien myötä on tottunut pitämään itsestään selvinä.

Viime vuosina koodatessani staattisesti tyypitetyillä kielilä, kuten TypeScriptillä, Scalalla, Javalla ja C++:lla, on geneerinen ohjelmointi tyyppiparametrejä hyödyntäen iskostunut selkärankaan. Golangin parissa kului hetki, ennen kuin aloin oivaltaa, miten sillä on mahdollista luoda yleisluontoisia funktioita ja rakenteita turhan toiston välttämiseksi. Kielestä puuttuvat nimittäin tyyppiparametrien ohella myös esimerkiksi sellaiset tavalliset geneerisen ohjelmoinnin ja polymorfismin apuvälineet kuten:

  • funktioiden ylimäärittely (function overloading)
  • ensimmäisen luokan tyypit (eli tyyppien käyttö arvoina, esim. funktioargumentteina)
  • oletusparametrit.

En vieläkään ole täysin tyytyväinen siihen, kuinka tehokkaasti kykenen tiivistämään yleisluontoisen logiikan ja tietorakenteet Golangilla geneeriseen muotoon. Matkan varrella olen kuitenkin oppinut paljon hyödyllisiä keinoja, joilla koodiaan voi kaunistaa ja tarpeettoman toiston määrää rajoittaa. Tässä ja seuraavassa blogitekstissä tarkastelemme näitä keinoja.

Upotettua rajapintaohjelmointia

Gon tyyppijärjestelmän keskeiset konseptit ovat tietueet (structs) ja rajapinnat (interfaces). Primitiivisiä tyyppejä, kuten int, bool ja string, lukuun ottamatta Gon tyypit määritellään C-tyylisinä tietueina, joille voi toteuttaa myös metodeja:

type Rect struct {
     Width int
     Height int
}

func (r Rect) Area() int {
     return r.Width * r.Height
}

Rajapintojen erikoisuus on puolestaan siinä, että tietuetyyppien ei tarvitse erikseen kertoa, että ne implementoivat tietyn rajapinnan. Sen sijaan Go-kääntäjä osaa automaattisesti tarkistaa, onko annettua tietuetta mahdollista sovittaa rajapintaan. Esimerkiksi alla oleva PrintArea-funktio hyväksyy automaattisesti Rect-tietueen instanssin, koska se on määritellyt rajapinnan ainoan vaatiman metodin, Arean:

type Shape interface {
     Area() int
}

func PrintArea(s Shape) {
     fmt.Println("The area of the shape is:", s.Area())
}

func main() {
     PrintArea(Rect{Width: 3, Height: 4})
}

Samoin funktio hyväksyisi esimerkiksi Circle-tietueen instanssin, jos Circle-tietueelle on määritelty Area-metodi. Jos tietueelta puuttuu Area-metodi, kääntäjä antaa virheen, jos sen instanssia yritetään syöttää argumenttina PrintArea-funktiolle.

Tietueita ja rajapintoja pystyy myös upottamaan (embed) toisiinsa. Tämä on Gon vastine olio-ohjelmoinnin perimiselle, mutta kyseessä ei ole täsmälleen sama asia. Alla on annettu esimerkki, jossa kaksi eri virhetietuetta upottavat sisäänsä geneerisen HTTPError-tietueen:

type HTTPError struct {
     code int
     message string
}

func (err HTTPError) Error() string {
     return fmt.Sprintf("%s (status=%d)", err.message, err.code)
}

type ResourceNotFound struct {
     HTTPError
}

func NewResourceNotFound(id string) ResourceNotFound {
     message := fmt.Sprintf("Resource with id %s not found", id)
     return ResourceNotFound{
          HTTPError: HTTPError{
               code: http.StatusNotFound,
               message: message,
          },
     }
}

type BadRequest struct {
     HTTPError
}

func NewBadRequest(reason string) BadRequest {
     message := fmt.Sprintf("Bad request caused by %s", reason)
     return BadRequest{
          HTTPError: HTTPError{
               code: http.StatusBadRequest,
               message: message,
          },
     }
}

Upottamista kannattaa luonnollisesti käyttää hyödyksi erottamaan tietueiden yhteiset osat yhteen paikkaan, jolloin vältetään saman koodin kirjoittaminen moneen kertaan. Näin saadaan aikaiseksi yleisluontoisempaa koodia. Tietue voidaan upottaa sellaisenaan tai pointterityyppinä.

Ensimmäinen tärkeä ero upottamisen ja perinnän välillä piilee siinä, että upottaminen on oikeastaan naamioitua kompositiota. Metodiin Error voidaan nyt viitata esimerkiksi ResourceNotFound-tietueen instanssista kahdella tavalla:

err := NewResourceNotFound("someresource")
fmt.Println(err.Error())
fmt.Println(err.HTTPError.Error())

Upotettavasta tietueesta siis muodostuu tosiasiassa yksi samanniminen kenttä tietueeseen, johon se upotetaan. Tässä tapauksessa upotettu HTTPError-tyyppi luo ResourceNotFound-tietueeseen HTTPError-nimisen kentän. Siksi HTTPError-kentälle pitää myös eksplisiittisesti alustaa arvo uutta ResourceNotFound-instanssia luodessa.

Toinen ero upottamisen ja perinnän välillä on siinä, ettei upottaminen toteuta Liskovin korvaussääntöä. Toisin sanoen HTTPError-tyyppisen muuttujan tai parametrin arvoksi ei voi asettaa ResourceNotFound- tai BadRequest-tyyppistä arvoa. Siksi ei olisi hyödyllistä määritellä esimerkiksi seuraavanlaista funktioita:

func SendHTTPError(err HTTPError, writer http.ResponseWriter) {
     /*...*/
}

Nyt err-parametrin paikalle ei voisi asettaa ResourceNotFound- tai BadRequest-tyyppisiä argumentteja. Sen sijaan kannattaa käyttää hyödyksi rajapintoja. Tässä vaiheessa onkin hyvä huomata, että Gossa on mahdollista luoda omia virhetyyppejä implementoimalla Gon sisäinen error-rajapinta:

type error interface {
     Error() string
}

func SendHTTPError(err error, writer http.ResponseWriter) {
     /*...*/
}

Tämä funktio pystyy ottamaan vastaan niin HTTPError-tyyppisen kuin ResourceNotFound- tai BadRequest-tyyppisenkin arvon, koska ne kaikki toteuttavat Error-metodin.

Keinot käytäntöön

Rajapinnat ja upottaminen ovat tärkeitä työkaluja Go-koodin muotoilemiseksi mahdollisimman polymorfiseen muotoon. Yleensä hyvä käytäntö on luoda omille julkisille tietueille vastaavat rajapinnat.

Usein parametrit, paluuarvot ja jäsenmuuttujat kannattaa myös tyypittää rajapinnoilla konkreettisten tietuetyyppien sijaan. Tämä mahdollistaa muun muassa niiden helpon korvaamisen mock-instansseilla testeissä. Ohessa on esitetty tästä esimerkki käyttäen testify-kirjaston suite-pakettia (https://github.com/stretchr/testify):

// Implementaatio:

import "net/http"

type IHTTPClient interface {
     Do(*http.Request) (*http.Response, error)
}

// MyClient käärii sisäänsä IHTTPClient-rajapinnan toteuttavan
// instanssin ja toteuttaa Get-metodin, joka käyttää clienttiä
// GET-pyynnön lähettämiseen.
type MyClient struct {
     uri string
     client IHTTPClient
}

func NewClient(uri string, client IHTTPClient) *MyClient {
     return &MyClient{uri, client}
}

func (c *MyClient) Get(path string) (*http.Response, error) {
     req, err := http.NewRequest(http.MethodGet, c.uri+path, nil)
     if err != nil {
          return nil, err
     }
     return c.client.Do(req)
}

// Pääohjelma

func main() {
     cli := NewClient("http://foo.com", new(http.Client))
     resp, err := cli.Get("/myresource")
     // ...
}

Ylläoleva implementaatiokoodi siis olettaa, että NewClient-funktiolle annetaan argumenttina jokin IHTTPClient-rajapinnan toteuttava arvo. Pääohjelmassa näin on tehty antamalla net/http-paketista löytyvän Client-tyypin pointteri.

Alla on puolestaan esitetty Clientia vastaava mock-tietue:

// Mock

import (
     "net/http"

     "github.com/stretchr/testify/mock"
)

type MockClient struct {
     mock.Mock
}

func (m *MockClient) Do(req *http.Request) (*http.Response, error) {
     args := m.Called(req)
     arg0 := args.Get(0)
     err := args.Error(1)
     if arg0 == nil {
          return nil, err
     }
     return arg0.(*http.Response), err
}

MockClient-tietueella on oman mockattu versionsa Do-metodista. Tämä mockattu metodi hyödyntää MockClientiin upotetun mock.Mock-tietueen tarjoamaa Called-metodia. Called-metodi tarkistaa, saapuivatko metodille oikeat argumentit, ja tuottaa testissä mockatut paluuarvot.

Lopuksi alla oleva testimme käyttää MockClientiä hyödyksi sijoittamalla sen IHTTPClientin paikalle NewClient-funktion argumentiksi. Näin voidaan tehdä, koska MockClient toteuttaa IHTTPClient-rajapinnan.

// Testi

import (
     "net/http"
     "testing"

     "github.com/stretchr/testify/suite"
)

type ClientSuite struct {
     suite.Suite
}

func (s *ClientSuite) TestGetReturnsResponse() {
     expected := new(http.Response)
     httpClient := new(MockClient)
     client := NewClient("/uri", httpClient)
     httpClient.On("Do", mock.AnythingOfType("*http.Request")).
          Return(expected, nil).Once()
     actual, err := client.Get("/path")
     httpClient.AssertExpectations(s.T())
     s.Same(expected, actual)
     s.Nil(err)
}

func TestClientGetSuite(t *testing.T) {
     suite.Run(t, new(ClientSuite))
}

Testitietueemme ClientSuite hyödyntää MockClientin mock.Mock-tietueelta saamia On- ja AssertExpectations-metodeja. On-metodilla voimme asettaa odotuksia MockClientin Do-metodin argumenteille, jotka Don sisällä kutsuttu Called tarkistaa. AssertExpectations-metodilla voimme taas tarkistaa ovatko nämä odotukset täyttyneet sen jälkeen, kun MyClient-instanssin Get-metodia kutsuttiin. Return puolestaan kertoo, mitä arvoja Calledin pitäisi palauttaa args-muuttujaan käärittynä, kun Do-metodia kutsutaan.

Huomaa, että testitietueemme upottaa sisäänsä myös suite.Suite-tietueen. Tämän ansiosta saamme käyttöömme muutamia käteviä apumetodeja testin tulosten tarkastamiseen, kuten Same- ja Nil-metodit.

Rajapintojen rajoituksia

Tarkastellaan seuraavaksi, millaisiin ongelmiin voimme rajapintojen kanssa törmätä.

Jos haluan esimerkiksi TypeScriptissä kirjoittaa oman geneerisen map-funktioni (enkä jostain syystä tahtoisi käyttää Array-objektin valmiiksi tarjoamaa metodia), se tapahtuisi seuraavasti:

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

Ja voin puolestaan käyttää funktiota näin:

const f = (x: number): number => x + 1
const incr = map([1, 2, 3], f) // [2, 3, 4]

Tämänkaltaisen geneerisen funktion toteuttaminen ei onnistukaan Golangissa aivan yhtä yksinkertaisesti. Kieli ei nimittäin tunne tyyppiparametreja.

Gossa lähin tyyppiparametreja T1 ja T2 vastaava konsepti on tyhjä rajapinta eli interface{}. Koska rajapinnalla ei ole lainkaan vaatimuksia toteutettaville metodeille, kaikki mahdolliset arvot toteuttavat sen. Seuraava funktio esimerkiksi tulostaa minkä tahansa arvon tyypin:

func PrintType(value interface{}) {
     fmt.Printf("The type of the value is: %T\n", value)
}

func main() {
    PrintType(Rect{Width: 3, Height: 4}) // main.Rect
    PrintType(100) // int
    PrintType(func() {}) // func()
}

Tyhjällä rajapinnalla emme kuitenkaan voi erotella kahta tuntematonta tyyppiä toisistaan. Se muistuttaa siis enemmän TypeScriptin ja monen muun kielen any-tyyppiä kuin geneeristä tyyppiparametria. Siksi jos yritämme toteuttaa aiemman kaltaista map-funktiota tyhjällä rajapinnalla, ajaudumme vaikeuksiin. Käytämme toteutuksessa nimeä mapElems, koska sana map on Gossa varattu kielen sisäiselle tietotyypille. Toteutus näyttää tältä:

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

Jos kuitenkin yritämme nyt kutsua funktiotamme samalla tavalla kuin TypeScript-koodissa yllä, saamme aikaan kääntäjävirheen:

func main() {
     f := func(x int) int { return x + 1 }
     mapElems([]int{1, 2, 3}, f)
}
cannot use []int literal (type []int) as type []interface {} in argument to mapElems

cannot use f (type func(int) int) as type func(interface {}) interface {} in argument to mapElems

Ensimmäinen virhe johtuu siitä, että vaikka tyyppi int sopiikin rajapintaan interface{}, leikkaustyyppi []int ei vastaa leikkaustyyppiä []interface{}. Leikkaus (slice) on Gon dynaaminen taulukkotyyppi.

Toinen virhe johtuu siitä, että annetun funktion f tyyppi func(int) int ei vastaa mapElemsin odottamaa funktiotyyppiä func(interface{}) interface{}. Tässä kohden onkin hyvä huomata, että vaikka Gosta puuttuu monia muille moderneille ohjelmointikielille tavanomaisia ominaisuuksia, sen funktiot ovat ensimmäisen luokan kansalaisia. Tämän ansiosta funktioita on mahdollista antaa argumenttina toisille funktioille. Funktioiden tyyppiannotaatioiden (type signature) on silti vastattava toisiaan.

Kokeillaan tehdä virheiden vaatimat korjaukset ja katsotaan, mitä tapahtuu:

func main() {
     f := func(x interface{}) interface{} { return x + 1 }
     mapElems([]interface{}{1, 2, 3}, f)
}

invalid operation: x + 1 (mismatched types interface {} and int)

Tämä virhe johtuu siitä, että x on nyt funktion f sisällä tyyppiä interface{} eikä sitä ole mahdollista summata yhteen int-tyyppiä edustavan numeron 1 kanssa. Korjataan asia tyyppiassertiolla, minkä jälkeen ratkaisumme toimii:

func main() {
     f := func(x interface{}) interface{} { return x.(int) + 1 }
     mapElems([]interface{}{1, 2, 3}, f)
}

Jos kuitenkin haluamme käyttää mapElemsin palauttamaa listaa millään tavalla hyödyksi, esimerkiksi antamalla sen simppelille summafunktiolle, päädymme ongelmiin. Paluuarvon tyyppi []interface{} on nimittäin yhteensopimaton tyypin []int kanssa:

func sum(arr []int) int {
     total := 0
     for _, x := range arr {
          total += x
     }
     return total
}

func main() {
     f := func(x interface{}) interface{} { return x.(int) + 1 }
     incr := mapElems([]interface{}{1, 2, 3}, f)
     fmt.Println("Sum of incremented values is:", sum(incr))
}

cannot use incr (type []interface {}) as type []int in argument to sum

Käytännössä joutuisimme iteroimaan läpi palautetun listan elementit, tulkitsemaan ne kokonaisluvuiksi ja lisäämään ne uuteen kokonaislukulistaan. Tässä vaiheessa käytännössä kaikki funktion hyödyt on kuitenkin jo menetetty. Ratkaisumme ei myöskään ole tyyppiturvallinen: jos ensimmäiseksi argumentiksi mapElemsille livahtaa jotain muuta kuin int-leikkaus, f-funktio panikoi eli ajautuu virhetilanteeseen tyyppiassertion kohdalla.

Johtopäätöksenä vedettäköön, että tapausta, jossa haluamme funktion ottavan vastaan minkä tahansa tyypin T ja paluuarvon tyypin riippuvan tästä tyypistä T, ei voi sellaisenaan ratkaista kovin mielekkäällä tavalla Gossa. Sen sijaan jää neljä vaihtoehtoa:

  1. Kirjoittaa jokaiselle tyypille oma versionsa funktiosta.
  2. Kirjoittaa funktion sisältö auki koodiin, kuten tällaisen yksittäisen for-silmukan tapauksessa.
  3. Käyttää tyyppiassertiota paluuarvolle.
  4. Refaktoroida koodi siten, ettei tilanteeseen päädytä alun alkaenkaan.

Vaihtoehdot 1 ja 2 ovat ymmärrettävästi helpoimmat, kun tyyppejä ei ole paljon ja funktiot ovat yksinkertaisia. Jos operaatio on sen sijaan monimutkainen tai tyyppejä, joille se tarvitsee tehdä, on lukuisia, täytyy keksiä jotain muuta. Muuten koodin ylläpitäminen muutosten tapauksessa muodostuu epämiellyttäväksi tehtäväksi. Blogijulkaisun seuraavassa osassa tutustumme erilaisiin keinoihin refaktoroida Go-koodia siten, että toisto on vältettävissä.

Vastaa

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