Valikoituja Go-haastattelukysymyksiä backend-kehittäjille aihepiireittäin ryhmiteltynä. Kysymykset on poimittu samasta tietokannasta, joka toimii EngineerSpeak-harjoittelun ytimenä.
1Selitä, miten Go-kielen nolla-arvot toimivat sisäänrakennetuille ja viittauksen kaltaisille tyypeille, ja miksi niillä on merkitystä, kun muuttujia esitellään ilman eksplisiittistä alustusta.
Go-kielessä muuttuja, joka on esitelty ilman eksplisiittistä alustusta, alustetaan automaattisesti tyyppinsä nolla-arvoon. Numeerisista tyypeistä tulee `0`, `bool`-tyypistä `false`, `string`-tyypistä `""`, ja taulukot (arrays) tai tietueet (`struct`) nollataan alkio alkiolta tai kenttä kentältä. Osoittimien tai viittausten kaltaisilla tyypeillä, kuten osoittimilla, `slice`- ja `map`-tyypeillä, kanavilla, funktioilla ja rajapinnoilla, on `nil` nolla-arvonaan. Tällä on merkitystä, koska Go-muuttujat ja pois jätetyt `struct`-kentät aloittavat deterministisessä tilassa roskadatan sijaan. Lisäksi monet API-rajapinnat on suunniteltu siten, että nolla-arvo on hyödyllinen oletusarvo, vaikka jotkin `nil`-arvot vaativat yhä alustuksen ennen tiettyjä toimenpiteitä.
2Miten Go käsittelee struct-tyyppien yhtäsuuruutta, ja mitä tapahtuu, kun struct sisältää kenttiä, jotka eivät ole vertailtavia?
Gossa `struct`-arvoja voidaan vertailla operaattoreilla `==` ja `!=` vain silloin, kun rakenteen jokainen kenttä on vertailtava. Yhtäsuuruus vertailee vastaavia kenttiä käyttäen kunkin kentän omaa vertailusääntöä. Jos `struct` sisältää kentän, joka ei ole vertailtava (kuten `slice`, `map` tai funktio), koko `struct`-tyyppi ei ole vertailtava, ja kahden kyseisen tyyppisen arvon vertaileminen `==`-operaattorilla aiheuttaa käännösaikaisen virheen. Tällaisten `struct`-tyyppien kohdalla on käytettävä mukautettua vertailulogiikkaa tai sopivaa syvävertailun (deep equality) apufunktiota, erityisesti testeissä.
3Selitä merkkijonojen muuttumattomuus (string immutability) Go-kielessä ja suhde string-, []byte-, bytes.Buffer- ja strings.Builder-tyyppien välillä.
Go-kielen `string` on muuttumaton tavujono, joka on usein UTF-8-muotoista tekstiä, mutta sen ei ole pakko olla validia UTF-8:aa. Merkkijonoa ei voi muokata paikallaan; sen sisällön muuttamiseksi se tyypillisesti muunnetaan `[]byte`-tyypiksi tavutason muokkauksia varten tai `[]rune`-tyypiksi koodipisteiden (code point) muokkauksia varten, ja sen jälkeen takaisin merkkijonoksi. Normaalit muunnokset `string`- ja `[]byte`-tyyppien välillä kopioivat dataa ja saattavat varata muistia, joten toistuvat muunnokset tai toistuva merkkijonojen yhdistäminen silmukoissa voivat olla raskaita. `strings.Builder` on optimoitu merkkijonojen tehokkaaseen rakentamiseen, kun taas `bytes.Buffer` on muokattava tavupuskuri, joka on hyödyllinen tavupohjaiselle datalle sekä I/O-operaatioille, ja josta voi myös tuottaa merkkijonon.
4Selitä, miten `nil` toimii eri tavoin osoittimien, `slice`- ja `map`-tyyppien, kanavien, funktioiden ja rajapintojen kohdalla Go-kielessä.
Go-kielessä `nil` on nolla-arvo osoittimille, `slice`- ja `map`-tyypeille, kanaville, funktioille ja rajapinnoille, mutta näillä `nil`-arvoilla tehtävät operaatiot vaihtelevat tyypin mukaan. `nil`-osoitinta voidaan verrata `nil`-arvoon, mutta sen purkaminen (dereferencing) aiheuttaa paniikin (`panic`). `nil`-arvoisen `slice`:n pituus ja kapasiteetti on 0, ja sen yli voidaan iteroida `range`-rakenteella ja siihen voidaan lisätä alkioita `append`-funktiolla. `nil`-arvoista `map`:ia voidaan lukea ja sen yli voidaan iteroida, mutta siihen sijoittaminen aiheuttaa paniikin. `nil`-kanavalle lähettäminen tai sieltä vastaanottaminen estää suorituksen ikuisesti, ja `nil`-kanavan sulkeminen aiheuttaa paniikin. `nil`-funktion kutsuminen aiheuttaa paniikin. Rajapinta on `nil` vain silloin, kun sillä ei ole dynaamista tyyppiä eikä dynaamista arvoa; tyypitetyn `nil`-arvon, kuten `nil`-osoittimen, sisältävä rajapinta ei itse ole `nil`.
5Mitä ovat vertailtavat tyypit (comparable types) Gossa, ja miten vertailtavuussäännöt vaikuttavat map-rakenteiden avaimiin, yhtäsuuruuteen ja generics-rajoitteisiin?
Vertailtavat tyypit Gossa ovat tyyppejä, joiden arvoja voidaan vertailla operaattoreilla `==` ja `!=`. Perustyypit, osoittimet, kanavat (channels), rajapinnat (interfaces) sekä sellaiset `struct`- ja taulukkotyypit, joiden kentät tai alkiot ovat vertailtavia, ovat vertailtavia tyyppejä. `slice`-, `map`- ja funktiotyypit eivät ole vertailtavia, paitsi arvoon `nil`. `map`-rakenteen avainten on oltava vertailtavia. Yhtäsuuruus noudattaa tyypin omia vertailusääntöjä, ja rajapintojen vertailu riippuu dynaamisista konkreettisista arvoista; jos vertailtava rajapinta sisältää dynaamisen arvon, joka ei ole vertailtava, vertailu aiheuttaa paniikin (panic). Generics-tyyppijärjestelmässä ennalta määritelty `comparable`-rajoite sallii tyyppiparametrien vertailun `==`/`!=` -operaattoreilla ja niiden käytön `map`-avaimina.
6Miten Go esittää tavuja, rune-merkkejä ja UTF-8-koodattua tekstiä, ja miksi len(s) voi poiketa käyttäjälle näkyvien merkkien määrästä?
Go-kielessä `byte` on alias `uint8`-tyypille ja se edustaa yhtä raakatavua, kun taas `rune` on alias `int32`-tyypille ja edustaa yhtä Unicode-koodipistettä. `string` on vain luku -tyyppinen tavujono, joka on yleensä UTF-8-koodattua tekstiä, mutta voi sisältää mielivaltaisia tavuja. `len(s)` palauttaa tavujen määrän, ei `rune`-merkkien tai käyttäjälle näkyvien merkkien määrää. Merkkijonon indeksointi palauttaa tavun; `range`-rakenteen käyttö merkkijonon yli purkaa UTF-8-koodauksen ja tuottaa tavuindeksien lisäksi `rune`-merkkejä. `len(s)` voi poiketa näkyvien merkkien määrästä, koska UTF-8 saattaa käyttää useita tavuja koodipistettä kohden, ja koska yksi käyttäjälle näkyvä merkki voi koostua useista koodipisteistä, kuten yhdistelmämerkeistä (combining marks) tai emojisarjoista.
7Kuvaile eroa Go-kielen taulukoiden ja `slice`-tyyppien välillä, mukaan lukien miten pituus, kapasiteetti ja taustalla oleva tallennustila käyttäytyvät.
Go-kielen taulukoilla on kiinteä pituus, joka on osa sen tyyppiä, kuten `[3]int`. Se tallentaa alkionsa suoraan, ja taulukon sijoittaminen muuttujaan tai välittäminen funktiolle kopioi koko taulukon arvon. `slice`, kuten `[]int`, on pieni kuvaaja taustalla olevasta taulukosta: käsitteellisesti se sisältää osoittimen alkioihin, pituuden ja kapasiteetin. `slice`-tyypin pituus on näkyvillä olevien alkioiden määrä; sen kapasiteetti määrittää, kuinka monta alkiota voidaan käyttää `slice`-alkukohdasta taustataulukon loppuun mennessä. `slice`-tyypit ovat joustavia: arvon viipalointi uudelleen (reslicing) muuttaa kuvaajaa, ja `append` voi käyttää uudelleen samaa taustataulukkoa, jos kapasiteetti sallii, tai varata uuden, jos se ei riitä.
8Kuinka Go:n `map`-tyyppi käyttäytyy suhteessa avaintyyppeihin, puuttuviin avaimiin, `nil`-arvoisiin `map`-rakenteisiin ja iteroinnin järjestykseen?
Go:n `map`-rakenteen avaintyyppien on oltava vertailukelpoisia (comparable); `slice`-, `map`- ja funktiotyyppejä ei voi käyttää suoraan avaimina. Puuttuvan avaimen hakeminen palauttaa alkiotyypin nolla-arvon, joten puuttuminen ja olemassa oleva nolla-arvo erotetaan toisistaan `comma-ok`-idiomilla (`v, ok := m[k]`). `nil`-arvoista `map`-rakennetta voi lukea ja sen voi käydä läpi `range`-rakenteella, mutta siihen sijoittaminen aiheuttaa paniikin (`panic`); se on siis alustettava ennen kirjoittamista. `map`-rakenteen iterointijärjestystä ei ole määritelty, eikä koodin tulisi luottaa siihen.
9Kuvaile, kuinka `slice`-tyyppien viipalointi uudelleen ja sijoittaminen voivat johtaa siihen, että useat `slice`-arvot jakavat saman taustataulukon, ja millaisia bugeja tämä voi aiheuttaa.
`slice`-arvo on otsikkorakenne (header), joka osoittaa taustalla olevaan taulukkoon. `slice`-arvon sijoittaminen tai välittäminen funktiolle kopioi vain tuon otsikkorakenteen, ei alkioita. Uudelleen viipalointi luo toisen otsikkorakenteen, joka osoittaa samaan taustataulukkoon tietylle välille. Tämän vuoksi useat `slice`-arvot voivat viitata samaan tallennustilaan: alkion muuttaminen yhden `slice`-arvon kautta voi näkyä toisessa, ja `append`-operaatio yhteen `slice`-arvoon voi ylikirjoittaa toiselle näkyvää dataa, jos siinä on vielä vapaata kapasiteettia. Tästä aiheutuvia bugeja ovat yllättävät muutokset, korruptoituneet tulokset, suurten taustataulukoiden tahaton muistissa pitäminen pienten `slice`-osien kautta sekä kilpailutilanteet (data race), kun aliaksia käytetään samanaikaisesti. Tahattoman jakamisen välttämiseksi tee puolustava kopiointi käyttämällä `copy`-funktiota tai lauseketta `append([]T(nil), s...)`, tai rajoita kapasiteettia koko kapasiteetin määrittävällä `slice`-lausekkeella ennen `append`-operaatiota.
10Selitä `slice`-rakenteen kasvaminen `append`-funktion aikana käsitteellisellä tasolla sekä toistuvien muistinvarauksien vaikutukset suorituskykyyn.
Kun `append` lisää elementtejä `slice`-rakenteeseen, se kirjoittaa ne olemassa olevaan taustataulukkoon (backing array), jos `slice`:lla on riittävästi kapasiteettia. Jos kapasiteetti ei riitä, Go varaa suuremman taustataulukon, kopioi olemassa olevat alkiot, kirjoittaa uudet alkiot ja palauttaa `slice`-otsikon, joka osoittaa uuteen muistialueeseen. Tarkka kasvatuskäytäntö riippuu toteutuksesta, mutta käsitteellisesti kapasiteetti kasvaa riittävästi, jotta toistuvat `append`-lisäykset ovat kuoletetusti tehokkaita (amortized efficient). Toistuvat uudelleenvaraukset kuluttavat silti suoritinaikaa kopiointiin, luovat uusia muistinvarauksia, lisäävät GC (Garbage Collector) -kuormitusta ja voivat rikkoa tilojen jaon (sharing) vanhojen `slice`-viittausten kanssa. Jos odotettu koko on tiedossa, varaa muisti ennakkoon komennolla `make([]T, 0, n)` rakentaessasi `append`-funktiolla, tai komennolla `make([]T, n)` täyttäessäsi taulukkoa indekseittäin, jotta vältyt turhilta muistinvarauksilta.
11Miten tyhjä tunniste (blank identifier) toimii Gossa käyttämättömien arvojen, tuontien ja käännösaikaisten rajapintatarkistusten osalta?
Tyhjä tunniste `_` on vain kirjoitusta tukeva paikkamerkki. Siihen sijoittaminen hylkää arvon eikä luo käyttökelpoista muuttujaa. Sitä käytetään tarpeettomien paluuarvojen tai silmukkamuuttujien ohittamiseen, paketin tuomiseen pelkkien sivuvaikutusten vuoksi muodossa `import _ "pkg"`, sekä käännösaikaisiin rajapintatoteutusten tarkistuksiin, kuten `var _ io.Reader = (*MyReader)(nil)`. Tyhjä tuonti suorittaa silti tuodun paketin alustuksen. Rajapintatarkistuksen sijoitus epäonnistuu kääntämisvaiheessa, jos konkreettisen tyypin metodijoukko ei täytä rajapinnan vaatimuksia.
12Miten pakettien alustusjärjestys toimii Go:ssa ottaen huomioon `init`-funktiot ja tuodut riippuvuudet?
Go alustaa paketit riippuvuusjärjestyksessä. Paketin tuodut riippuvuudet alustetaan aina ennen ne tuovaa pakettia. Paketin sisällä pakettitason muuttujat alustetaan ennen mahdollisia `init`-funktioita, ja muuttujien alustus tapahtuu kielen määrittelemässä riippuvuus- ja esittelyjärjestyksessä. Tämän jälkeen paketin `init`-funktiot suoritetaan automaattisesti; yhdellä paketilla voi olla useita `init`-funktioita, eikä niitä ole mahdollista kutsua suoraan koodista. Jokainen paketti alustetaan vain kerran. Suoritettavan ohjelman tapauksessa tuontiverkko alustetaan ensin, sitten alustetaan `main`-paketti, ja lopuksi kutsutaan `main.main`-funktiota.
13Selitä Go-kielen pakettien näkyvyyssäännöt, mukaan lukien viedyt tunnisteet (exported identifiers) ja `internal/`-hakemistokäytäntö.
Go-kielessä pakettien näkyvyyttä hallitaan tunnisteiden nimeämisellä, ei näkyvyysavainsanoilla. Tunniste, jonka nimi alkaa isolla Unicode-kirjaimella, on viety (exported) ja siihen voidaan viitata muista paketeista; muut tunnisteet ovat sisäisiä (unexported), ja niitä voidaan käyttää vain saman paketin sisällä. Tämä koskee funktioita, tyyppejä, metodeja, muuttujia, vakioita ja `struct`-kenttiä. Paketit käyttävät vietyjä tunnisteita julkisen rajapintansa määrittelyyn ja pitävät toteutusyksityiskohdat sisäisinä. Erillisenä sääntönä `internal/`-hakemiston alla sijaitsevan paketin voi tuoda vain koodi, jonka tuontipolku kuuluu kyseisen `internal`-hakemiston ylähakemistopuuhun; tätä valvoo Go-työkaluketju.
14Miten `defer` toimii Go-kielessä, mukaan lukien suoritusjärjestys, argumenttien evaluointiaika ja vuorovaikutus paluuarvojen kanssa?
`defer` ajastaa funktiokutsun suoritettavaksi, kun ympäröivästä funktiosta poistutaan, riippumatta siitä, poistutaanko siitä normaalilla paluulla vai `panic`-tilan purkamisen kautta. Useat viivästetyt kutsut suoritetaan LIFO (Last-In, First-Out) -järjestyksessä. Viivästetyn funktion arvo ja sen argumentit evaluoidaan välittömästi, kun `defer`-lause suoritetaan, mutta itse kutsu ajetaan myöhemmin. Nimetyillä paluuarvoilla `return`-lause sijoittaa paluuarvot ensin, minkä jälkeen viivästetyt funktiot suoritetaan. Tämän ansiosta viivästetty sulkeuma voi havainnoida tai muokata nimettyjä tulosmuuttujia ennen kuin kutsuja vastaanottaa ne. Tämä tekee `defer`-lauseesta hyödyllisen siivoustoimenpiteissä, kuten tiedostojen sulkemisessa, muteksien lukituksen avaamisessa ja resurssien vapauttamisessa.
15Selitä Go-kielen virheidenkäsittelymalli ja tavanomaiset tavat, joilla virheitä luodaan, palautetaan ja tarkistetaan.
Go käsittelee virheitä tavallisina arvoina, ei poikkeuksina. Mikä tahansa tyyppi, jolla on `Error() string` -metodi, toteuttaa sisäänrakennetun `error`-rajapinnan. Funktiot palauttavat tavanomaisesti `error`-arvon viimeisenä tuloksenaan, jolloin `nil` tarkoittaa onnistumista ja siitä poikkeava virhe tarkoittaa, että kutsujan on käsiteltävä tai välitettävä virhe eteenpäin. Yksinkertaisia virheitä luodaan yleensä `errors.New`-funktiolla, muotoiltuja virheitä `fmt.Errorf`-funktiolla, ja kutsujat tarkistavat tyypillisesti virheet muodossa `if err != nil { ... }`.
16Miten `panic`-tilanteista toipumista tulisi käsitellä Go-taustapalveluissa, mukaan lukien mitä tapahtuu, kun `goroutine` panikoi, ja milloin prosessin tulisi toipua kaatumisen sijaan?
`panic` purkaa nykyisen `goroutine`:n kutsupinon ja suorittaa sen viivästetyt funktiot (`defer`). `recover` toimii vain, kun sitä kutsutaan saman `goroutine`:n viivästetystä funktiosta; yksi `goroutine` ei voi toipua toisen `goroutine`:n `panic`-tilanteesta. Jos `panic`-tilanteesta ei toivuta, koko prosessi kaatuu. Taustapalveluissa toipuminen tulisi yleensä sijoittaa eristysrajoille, kuten pyyntöjen käsittelijöihin, RPC (Remote Procedure Call) -välikerroksiin tai taustatehtäviä suorittavien `goroutine`-rutiinien aloituspisteisiin, jotta yksi epäonnistuva pyyntö tai työ ei kaada koko palvelua. Mutta jos on mahdollista, että `panic` on korruptoinut jaettua tilaa tai tehnyt prosessin eheydestä epäluotettavan, on turvallisempaa antaa prosessin kaatua ja käynnistyä uudelleen sen sijaan, että se toipuisi ja jatkaisi toimintaansa sokeasti.
17Mitä ovat `nil`-kanavat Go-kielessä, ja miten ne voivat vahingossa rikkoa koodia tai tarkoituksella poistaa `select`-rakenteen `case`-vaihtoehtoja käytöstä?
`nil`-kanava on kanavamuuttuja, jonka arvo on `nil`. Tämä johtuu usein siitä, että sitä ei ole alustettu `make`-funktiolla tai se on nimenomaisesti asetettu arvoon `nil`. `nil`-kanavalle lähettäminen tai siltä lukeminen estää suorituksen ikuisesti. `select`-rakenteessa `nil`-kanavaa käyttävä `case` ei ole koskaan valmis suoritettavaksi, joten kanavamuuttujan asettaminen arvoon `nil` voi tarkoituksella poistaa kyseisen `case`-vaihtoehdon käytöstä. `nil`-kanavan käyttö vahingossa voi aiheuttaa goroutine-säikeiden jumiutumisen tai estää `select`-logiikkaa käsittelemästä odotettuja tapahtumia.
18Miten `sync/atomic`-paketin atomiset operaatiot eroavat mutex-pohjaisesta synkronoinnista, ja milloin niiden käyttö on perusteltua?
`sync/atomic`-paketti tarjoaa yksittäisiin muistipaikkoihin kohdistuvia jakamattomia operaatioita, kuten lataus, tallennus, lisäys, vaihto sekä vertailu ja vaihto (compare-and-swap), taaten samalla synkronoinnin ja muistioperaatioiden järjestyksen (memory ordering). Mutex-lukko suojaa kriittistä aluetta, joten se voi suojata mielivaltaista koodia ja invariantteja, jotka koskevat useita luku- ja kirjoitusoperaatioita tai kenttiä. Atomiset operaatiot sopivat yksinkertaisiin, toisistaan riippumattomiin tiloihin, kuten laskureihin, tilalippuihin, järjestysnumeroihin tai huolellisesti suunniteltuihin lukottomiin (lock-free) tietorakenteisiin. Suosi mutex-lukkoa, kun operaatiot ovat moniosaisia, useiden arvojen on pysyttävä yhdenmukaisina tai kun koodin atomista versiota olisi vaikea hahmottaa tai todistaa oikeaksi.
19Kuinka kanavien omistajuus ja goroutine-rutiinien elinkaari tulisi suunnitella, jotta vältytään goroutine-vuodoilta?
Suunnittele `goroutine`-rutiineille selkeä omistaja, yksiselitteinen sammutussignaali ja taattu poistumisreitti. Tuottajapuoli vastaa yleensä kanavan sulkemisesta, erityisesti tulostekanavien osalta; vastaanottajat eivät saisi sulkea kanavaa silloin, kun lähettäjiä saattaa olla vielä aktiivisina. Jokaisen estävän lähetys- tai vastaanotto-operaation, silmukan, ajastimen tai ulkoisen kutsun on joko taatusti valmistuttava tai vapauduttava peruutuksen yhteydessä, yleensä `context.Context`-rajapinnan tai `done`-kanavan avulla. Käytä `WaitGroup`-, `errgroup`- tai vastaavia koordinaatiomekanismeja, jotta työntekijöiden valmistumista odotetaan ja kanavat suljetaan vasta sen jälkeen, kun lähettäjät ovat lopettaneet toimintansa.
20Mitkä ovat yleisimpiä syitä goroutine-vuotoihin Go-palveluissa, ja miten ne havaitaan ja korjataan tuotannossa?
Yleisimpiä syitä goroutine-vuotoihin Go-palveluissa ovat ikuisesti kanavien lähetys- tai vastaanotto-operaatioihin jumiutuneet goroutinet, muiden estävien operaatioiden odottaminen ilman peruutusta, jumiutunut I/O (Input/Output) ilman aikarajoja, taustasilmukat tai `ticker`-ajastimet, jotka eivät koskaan pysähdy, sekä pyyntökohtaiset goroutinet, jotka jatkavat suoritustaan alkuperäisen pyynnön päätyttyä. Tuotannossa etsitään jatkuvaa kasvua goroutine-määrissä ja siihen liittyviä oireita, minkä jälkeen tutkitaan goroutine-vedoksia tai `pprof`-työkalun goroutine-profiileja, jotta nähdään mihin goroutinet ovat jumiutuneet. Vuodon korjaaminen tarkoittaa koodin muuttamista siten, että kyseiset goroutinet pystyvät poistumaan: lisää peruutuksia ja aikarajoja, pysäytä `ticker`-ajastimet, sulje kanavat oikein, vältä irtaantuneita pyyntökohtaisia goroutineja ja rajoita rinnakkaisuutta tarvittaessa.