1Обяснете как работят нулевите стойности в Go за вградените и референтните типове и защо са важни при деклариране на променливи без изрична инициализация.
В Go променлива, декларирана без изрична инициализация, автоматично се инициализира до нулевата стойност за нейния тип. Числовите типове стават `0`, `bool` става `false`, `string` става `""`, а масивите или `struct` типовете се зануляват елемент по елемент или поле по поле. Типовете, подобни на указатели или референции, като указатели, `slice` стойности, `map` структури, канали, функции и интерфейси, имат `nil` като своя нулева стойност. Това е важно, защото променливите в Go и пропуснатите `struct` полета започват в детерминирано състояние, вместо да съдържат произволни данни от паметта, и много програмни интерфейси са проектирани така, че нулевата стойност да е полезна стойност по подразбиране. Въпреки това, някои `nil` стойности все още изискват инициализация преди определени операции.
package main
import "fmt"
type Config struct {
Port int
Debug bool
Name string
Tags []string
Options map[string]string
}
func main() {
var c Config
fmt.Printf("port=%d debug=%v name=%q tags==nil:%v options==nil:%v\n",
c.Port, c.Debug, c.Name, c.Tags == nil, c.Options == nil)
}
2Как Go се справя с равенството при структури и какво се случва, когато една структура съдържа несравними полета?
Стойностите на структурите в Go могат да бъдат сравнявани с `==` и `!=` само когато всяко поле в структурата е сравнимо. Равенството сравнява съответните полета, използвайки правилата за равенство на всяко отделно поле. Ако една структура съдържа несравнимо поле, като например `slice`, `map` или функция, типът на структурата не е сравним и сравняването на две стойности от този тип структура с `==` води до грешка по време на компилация. За такива структури трябва да се използва персонализирана логика за сравнение или подходяща помощна функция за дълбоко сравнение, особено в тестове.
package main
import "fmt"
type UserID struct {
Name string
Age int
}
type UserProfile struct {
Name string
Tags []string
}
func main() {
a := UserID{"Ann", 30}
b := UserID{"Ann", 30}
fmt.Println(a == b)
x := UserProfile{Name: "Ann", Tags: []string{"go"}}
y := UserProfile{Name: "Ann", Tags: []string{"go"}}
_, _ = x, y
// fmt.Println(x == y) // compile error: struct containing []string cannot be compared
}
3Обяснете неизменяемостта на низовете в Go и връзката между `string`, `[]byte`, `bytes.Buffer` и `strings.Builder`.
В Go типът `string` е неизменяема последователност от байтове, често UTF-8 текст, но не се изисква да бъде валиден UTF-8. Не можете да променяте низ на място; за да промените съдържанието, обикновено преобразувате в `[]byte` за редакции на ниво байт или `[]rune` за редакции на ниво кодова точка, след което преобразувате обратно. Нормалните преобразувания между `string` и `[]byte` копират данни и могат да заделят памет, така че повтарящите се преобразувания или повтарящото се конкатениране в цикли могат да бъдат скъпи. Типът `strings.Builder` е оптимизиран за ефективно изграждане на низове, докато `bytes.Buffer` е изменяем буфер от байтове, полезен за байтово-ориентирани данни и входно-изходни операции, и също може да произведе низ.
package main
import (
"bytes"
"fmt"
"strings"
)
func main() {
var sb strings.Builder
sb.WriteString("hello")
sb.WriteByte(' ')
sb.WriteString("world")
fmt.Println(sb.String())
var buf bytes.Buffer
buf.Write([]byte{0x48, 0x69})
buf.WriteByte('!')
fmt.Println(buf.String())
s := "cat"
b := []byte(s) // normally copies
b[0] = 'b'
fmt.Println(s, string(b))
}
4Как работят указателите в Go и кои операции са умишлено забранени в сравнение с C?
Указателят в Go е типизирана стойност, която съдържа адреса на друга стойност, като например `*int`, който сочи към `int`. Използвайте `&x`, за да вземете адреса на дадена стойност, и `*p`, за да дереференцирате указател с цел четене или запис на стойността, към която той сочи. Подаването или съхраняването на указател позволява на множество места в кода да наблюдават или променят една и съща базова стойност, като указателят може да бъде `nil`. За разлика от C, безопасният Go умишлено забранява аритметиката с указатели и произволните манипулации на сурови адреси; изключенията на ниско ниво изискват използването на пакета `unsafe`.
package main
import "fmt"
func inc(p *int) {
*p = *p + 1
}
func main() {
x := 10
p := &x
inc(p)
fmt.Println(x)
}
5Обяснете как работят константите и `iota` в Go, включително типизираните спрямо нетипизираните константи.
Константите в Go са стойности, определяни по време на компилация: булеви, низови или числови. Типизираната константа има специфичен тип. Нетипизираната константа няма фиксиран конкретен тип, докато не бъде използвана в типизиран контекст, а числовите нетипизирани константи се представят точно и с висока прецизност до този момент; стойността трябва да може да бъде представена в избрания тип. `iota` е предварително деклариран идентификатор, който се използва в `const` декларации: той започва от 0 във всеки `const` блок и се увеличава за всяка спецификация на константа, което го прави полезен за константи от тип изброяване (enum) и битови флагове.
type Status int
const (
StatusPending Status = iota // 0, typed Status
StatusActive // 1, same expression/type repeated
StatusClosed // 2
)
const big = 1 << 100 // untyped integer constant with high precision
var x int64 = 42 // untyped 42 can become int64
// var y int64 = big // compile error: not representable as int64
6Как работят явните преобразувания в Go и защо езикът избягва много от неявните преобразувания?
Go като цяло изисква явни преобразувания чрез `T(x)`, когато се променя типът на дадена стойност, като например `int64(i)` или `MyID(n)`. Това прави промените на типа видими и предотвратява изненадващи неявни числови, булеви или низови преобразувания. Преобразуванията са позволени само според правилата на Go и могат да променят стойността, например чрез препълване или отрязване на цели числа, отрязване към нулата при преобразуване на числа с плаваща запетая към цели числа или загуба на точност. Нетипизираните константи са по-гъвкави: те могат да се използват в типизиран контекст, ако стойността на константата може да бъде представена в този тип.
var x int64 = 300
var y int8 = int8(x) // explicit conversion; value changes
var f float64 = 3.9
var i int = int(f) // truncates toward zero
const c = 42
var n int32 = c // ok: untyped constant is representable as int32
7Опишете разликата между масиви и `slice` стойности в Go, включително как се държат дължината, капацитетът и базовата памет за съхранение.
Един масив в Go има фиксирана дължина, която е част от неговия тип, като например `[3]int`; той съхранява своите елементи директно, а присвояването или подаването на масив копира цялата стойност на масива. Една `slice` стойност, като `[]int`, е малък дескриптор над базов масив: концептуално тя съдържа указател към елементите, дължина и капацитет. Дължината на `slice` е броят на видимите елементи; капацитетът му е броят елементи, които могат да бъдат използвани от началото на `slice` стойността, преди да се достигне краят на базовия масив. Стойностите от тип `slice` са гъвкави: промяната на границите им променя дескриптора, а функцията `append` може да използва повторно същия базов масив, ако капацитетът позволява, или да задели памет за нов, ако не позволява.
package main
import "fmt"
func main() {
a := [3]int{1, 2, 3}
b := a
b[0] = 99
fmt.Println(a, b)
s := []int{1, 2, 3}
t := s
t[0] = 99
fmt.Println(s, t)
fmt.Println(len(s), cap(s))
}
8Как се държи типът `map` в Go по отношение на типовете на ключовете, липсващите ключове, `nil` `map` стойностите и реда на итерация?
Типовете на ключовете за `map` в Go трябва да бъдат сравними; `slice` и `map` стойности, както и функции, не могат да се използват директно като ключове. Търсенето на ключ, който не присъства, връща нулевата стойност за типа на елемента, така че се използва форматът със запетая и `ok` (`v, ok := m[k]`), за да се разграничи липсващ ключ от присъстваща нулева стойност. От `nil` `map` може да се чете и той може да бъде обхождан с `range`, но присвояването в него предизвиква паника (`panic`); инициализирайте го преди запис. Редът на итерация на `map` не е дефиниран и кодът не трябва да зависи от него.
package main
import "fmt"
func main() {
counts := map[string]int{"a": 0, "b": 2}
fmt.Println(counts["missing"]) // zero value for int
v, ok := counts["a"]
fmt.Println(v, ok) // present even though value is zero
var m map[string]int
fmt.Println(m["x"]) // read from nil map is OK
// m["x"] = 1 // panic: assignment to entry in nil map
m = make(map[string]int)
m["x"] = 1
for k, v := range counts {
fmt.Println(k, v) // order is not guaranteed
}
}
9Кога трябва да предавате `struct` или друга стойност по стойност спрямо по указател в Go?
Предавайте по стойност, когато стойността е малка, само за четене при извикването или когато искате независима семантика на копиране. Предавайте по указател, когато функцията трябва да променя стойността на извикващия, копирането би било скъпо, типът не трябва да се копира или `nil` / споделената идентичност е част от програмния интерфейс. Не приемайте, че указателите са винаги по-бързи: те могат да добавят проблеми с използването на псевдоними (aliasing), изтичане към динамичната памет (heap escapes), работа за GC (Garbage Collector) и по-неблагоприятен достъп до кеша. Предпочитайте първо ясната семантика и използвайте тестове за производителност за избори, критични за бързодействието.
package main
import "fmt"
type Counter struct{ N int }
func Read(c Counter) int {
c.N++
return c.N
}
func Inc(c *Counter) {
c.N++
}
func main() {
c := Counter{N: 5}
fmt.Println(Read(c), c.N)
Inc(&c)
fmt.Println(c.N)
}
10Как работят присвояването и копирането при `struct` типове, масиви, `map` и `slice` стойности в Go?
Присвояването в Go копира стойността, която се присвоява, но ефектът зависи от типа. Присвояването на `struct` копира полетата на структурата, а при масивите се копира всеки елемент. Присвояването на `slice` копира заглавната част – указател, дължина и капацитет – поради което различните `slice` променливи обикновено споделят един и същ базов масив. Присвояването на `map` копира дескриптор, който работи като псевдоним, така че и двете променливи сочат към едни и същи данни. За независимо управление на собствеността трябва да се направи защитно копие, например чрез използване на `copy` или `slices.Clone` за `slice` стойности, а при `map` стойности – чрез създаване на нов `map` и ръчно копиране на записите.
package main
import "fmt"
type Point struct{ X int }
func main() {
a := Point{X: 1}
b := a
b.X = 2
x := [2]int{1, 2}
y := x
y[0] = 99
fmt.Println(a.X, b.X)
fmt.Println(x, y)
}
11Как затварянията (closures) прихващат променливи в Go и какви проблеми могат да възникнат при цикли и горутини (goroutines)?
Едно затваряне в Go е функционална стойност, която реферира променливи от обкръжаващата я лексикална област на видимост. Тя прихваща самите променливи, така че промените могат да бъдат наблюдавани от затварянето, а прихванатите променливи могат да живеят по-дълго от създаващата ги функция, ако затварянето излезе извън нейния обхват. Проблеми около цикли и горутини възникват, когато затварянията споделят или наблюдават дадена променлива след нейната промяна. Това често води до ситуации, в които горутините или отложените обратни извиквания виждат нежелана стойност, или причинява състезания за данни (data races) върху споделено състояние. Обичайното решение е очакваната стойност да се подаде като аргумент на затварянето или да се създаде ново локално копие за всяка итерация, както и да се синхронизира паралелният достъп, когато е необходимо. От версия Go 1.22 насам, променливите в цикли, декларирани с `for`/`range`, се инициализират за всяка итерация, което премахва много от класическите бъгове с прихващане на променливи. Въпреки това, преизползваните променливи извън цикъла и друго споделено изменяемо състояние все още могат да причинят проблеми.
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(v int) {
defer wg.Done()
fmt.Println(v)
}(i)
}
wg.Wait()
}
12Опишете семантиката на `range` при обхождане на масиви, `slice` стойности, `map` колекции, низове и канали.
`range` итерира според типа на операнда. При масив или `slice` връща индекс и стойност на елемента; стойността е копие, така че присвояването на нова стойност към нея не променя колекцията. Обхождането с `range` на стойност от тип масив копира целия масив за итерацията, докато при `slice` се използва заглавната част (slice header) и се индексира базовият масив. При `map` връща ключ и стойност в неопределен ред, като стойността е копие. При низ връща байтовия индекс и декодираната Unicode кодова точка (`rune`), а не индекса на позицията на самата `rune`. При канал приема стойности, докато каналът не бъде затворен и изпразнен; обхождането на `nil` канал блокира завинаги.
package main
import "fmt"
func main() {
xs := []int{1, 2, 3}
for _, v := range xs {
v *= 10 // modifies only the copy
}
fmt.Println(xs)
for i := range xs {
xs[i] *= 10 // modifies the slice element
}
fmt.Println(xs)
}
13Каква е разликата между приемници по стойност и приемници по указател и как изборът на приемник влияе върху изменяемостта, копирането и удовлетворяването на интерфейси?
Метод с приемник по стойност получава копие на приемника, така че е подходящ за поведение само за четене и малки неизменяеми типове; промените в копието на приемника не са видими за извикващия. Метод с приемник по указател получава копие на указател към приемника, така че може да променя оригинала и да избягва копирането на големи стойности или такива, които не трябва да се копират. Изборът на приемник влияе върху `method set`: методите с приемници по стойност са в `method set` както на `T`, така и на `*T`, докато методите с приемници по указател са само в `method set` на `*T`. Следователно, интерфейс, изискващ метод с приемник по указател, се удовлетворява от `*T`, а не от `T`, въпреки че адресируема стойност от тип `T` може да извика метода, използвайки обикновения синтаксис за извикване на методи.
package main
import "fmt"
type Counter struct{ N int }
func (c Counter) AddValue() { c.N++ }
func (c *Counter) AddPointer() { c.N++ }
func main() {
c := Counter{N: 1}
c.AddValue()
fmt.Println(c.N)
c.AddPointer() // compiler rewrites to (&c).AddPointer() because c is addressable
fmt.Println(c.N)
}
14Опишете как работят интерфейсите в Go и какво означава даден тип да удовлетворява интерфейс неявно.
Интерфейсът в Go дефинира набор от задължителни методи. Даден конкретен тип удовлетворява интерфейс неявно, когато неговият набор от методи съдържа тези методи; няма изрична декларация за внедряване. Променлива от тип интерфейс може да съдържа конкретна динамична стойност, чийто тип удовлетворява интерфейса, и извикванията на методите на интерфейса се пренасочват към имплементацията на тази динамична стойност. Празният интерфейс, който се записва като `interface{}` или обикновено `any`, няма задължителни методи, така че всеки тип го удовлетворява. Дали `T`, `*T` или и двете удовлетворяват даден интерфейс, зависи от техните набори от методи и избора на получател.
package main
import "fmt"
type Greeter interface {
Greet() string
}
type User struct{ Name string }
func (u User) Greet() string { return "hello, " + u.Name }
func Say(g Greeter) {
fmt.Println(g.Greet())
}
func main() {
Say(User{Name: "Ana"}) // no 'implements Greeter' declaration needed
}
15Как работят утвърждаванията на тип (type assertions) и switch конструкциите по тип (type switches) със стойности на интерфейси?
Утвърждаването на тип проверява динамичната стойност, съхранена в интерфейс: `v := x.(T)` е успешно, ако динамичният тип на стойността на интерфейса е `T` или ако `T` е интерфейс, имплементиран от динамичната стойност. Формата с един резултат предизвиква паника, ако проверката не успее. Формата със запетая и `ok`, `v, ok := x.(T)`, отчита успех, без да предизвиква паника. Конструкцията за избор по тип, `switch v := x.(type)`, се разклонява въз основа на динамичния тип на стойността на интерфейса, като `v` получава типа, съответстващ на съвпадналия случай.
func describe(x any) string {
if s, ok := x.(string); ok {
return "string: " + s
}
switch v := x.(type) {
case int:
return fmt.Sprintf("int: %d", v)
case fmt.Stringer:
return "stringer: " + v.String()
case nil:
return "nil interface"
default:
return fmt.Sprintf("unknown %T", v)
}
}