Привет, Хабр! Эта статья - начало цикла статей на то, как сейчас проходят интервью на golang разработчика (без привязки к грейду).
Цикл будет в себя включать как теоретические вопросы (с примерами и кодом исходников языка), так и реальные практические задачи, которые спрашивают при устройстве на позиции go разработчика.
Собеседование Golang разработчика (теоретические вопросы), Часть I
Собеседование Golang разработчика (теоретические вопросы), Часть II. Что там с конкурентностью?
Оглавление
Предыстория
Немного предыстории. У меня не очень много опыта на go, пишу на нем я чуть менее 3-х лет. В определенный момент, научившись писать неплохой продуктовый код и решать проблемы бизнеса, я пришел к выводу, что не понимаю многих аспектов. Почему так, а не иначе работает тот или иной интрумент языка программирования, на котором я пишу? Это побудило меня начать копать вширь и вглубь. Кроме того, для закрытия пробелов в знаниях, я начал проходить собеседования. Прямой цели сменить работодателя у меня не было, при этом, я понимал, что интервью - это реальный способ повысить свой технический уровень. Потому что, многие из тех вопросов, которые мы решаем в повседневной жизни, не требуют глубинного понимания инструментов, с помощью которых они решаются.
В этой статье я хотел бы начать делиться с вами реальными вопросами, которые у меня спрашивали интервьюеры, а также ответами на них, которые в конечном итоге удовлетворяли этих самых интервьюеров. Как итог, ответы на эти вопросы помогли мне получить хорошие оферы. Также, хочу сказать, что, конечно, уровень вопросов и задач зачастую зависит от той позиции, на которую вы проходите собеседование. К моему удивлению, многие вопросы и задачи в равной степени спрашивают на любую из позиций - джуна, мидла, синьора. Зачастую интервьюеры пытаются вытянуть максимум глубинных знаний, которые может показать кандидат, чтобы адекватно оценить его технический уровень. По этой причине будьте готовы, что любой из перечисленных в статье вопросов попросят раскрыть более широко.
ООП в Golang
Один из вопросов, которые любят задавать кандидатам с самого начала собеседования - это "Как реализовано ООП в go?".
Ответ
Сейчас мы не будем вдаваться в то, что такое ООП. Об этом можно почитать на Хабре на других ресурсах. Информации про это великое множество.
На сам вопрос можно ответить, что в go нет классической реализация ООП, так как он не объектно-ориентированный язык. При этом в go есть свои приближения к этой реализации. Сейчас поговорим об этом конкретнее.
"Как реализовано наследование?"
Ответ
Как такового наследования в go нет, но при этом у нас есть структуры - это специальные типы, в которые мы можем включать другие типы, в том числе такие же структуры. При этом методы дочерних структур родительская структура также будет наследовать.
Отсюда интервьюер может задать вопрос: "Что будет, если и в родительской и дочерней структуре есть реализация методов с одинаковым названием?" Ответ на этот вопрос такой: "Реализация родительского метода будет переписана реализацией дочернего метода".
type Parent struct{}
func (c *Parent) Print() {
fmt.Println("parent")
}
type Child struct {
Parent
}
func (p *Child) Print() {
fmt.Println("child")
}
func main() {
var x Child
x.Print()
}
Вывод
child
"Как реализована Инкапсуляция в go?"
Ответ
Инкапсуляция в go - это возможность задавать переменным, функциям и методам первую букву названия в верхнем или нижнем регистре. Соответственно нижний регистр будет значить, что переменная, функция или метод доступна только в рамках пакета. Тогда как верхний регистр даст доступ к переменной, функции или методу за рамками пакета.
"Как реализован полиморфизм в go?"
Ответ
Полиморфизм в go реализован с помощью интерфейсов. Что такое интерфейс мы будем разбираться ниже. Основная идея заключается в том, что мы можем объявить интерфейсы (контракты на определённое поведение) для наших типов. При этом, для типов мы должны реализовать методы, удовлетворяющие этим интерфейсам. Таким образом, мы сможем работать со всем набором типов, у которых реализовали интерфейсы, как с единым интерфейсным типом.
Области видимости в Golang
"Что такое пакеты в go?"
Ответ
Пакет - это механизм переиспользования кода, при котором go файлы помещаются в общую директорию. В начале каждого такого файла объявляется зарезервированное слово package
, а после него прописывается имя пакета. В рамках пакета все функции и глобальные переменные, объявленные как в верхнем, так и в нижнем регистре, видят друг друга.
"Что такое глобальная переменная?"
Ответ
Глобальная переменная - это переменная уровня пакета, то есть объявленная вне функции. Глобальная переменная также может быть доступна за рамками пакета, конечно только в том случае, если ее наименование начинается в верхнем регистре.
Один из вопросов на общие знания, которые вы вряд ли когда-то будете использовать в go - это вопрос "Что такое фигурные скобки с не объявленным оператором в go функции?".
Ответ
В go функции действительно можно объявить{}
без оператора, ограничив область видимости куска кода в рамках этой функции.
func main() {
solder := "Bill"
{
solder := "Den"
fmt.Println(solder)
}
fmt.Println(solder)
}
Вывод
Den
Bill
Операторы в Golang
Про операторы в go в основном спрашивают на код секциях, к примеру захват переменной в цикле и так далее. Но все же, однажды попался следующий вопрос.
"В go есть оператор switch case, можно ли выполнить несколько условий в одном объявленном операторе?"
Ответ
Такое возможно благодаря ключевому слову fallthrough
. Оно заставляет выполнять код в следующей объявленной булевой секции, вне зависимости подходит ли булевое условие case
этой секции.
func main() {
animals := []string{"bear", "bear", "rabbit", "wolf"}
for _, animal := range animals {
switch animal {
case "rabbit":
fmt.Println(animal, "is so weak!")
fallthrough
case "bear", "wolf":
fmt.Println(animal, "is so strong!")
}
}
}
Вывод
bear is so strong!
bear is so strong!
rabbit is so weak!
rabbit is so strong!
wolf is so strong!
Strings в Golang
"Что представляют из себя строки в go?"
Ответ
Строки в go - это обычный массив байт. Это надо понимать для того, чтобы ответить на следующие вопросы о строках.
type _string struct {
elements *byte // underlying bytes
len int // number of bytes
}
"Как можно оперировать строками?"
Ответ
Строки в go можно складывать(конкатенировать). Для многих операций есть стандартные пакеты, к примеру strings
, fmt
. Кроме того, надо понимать, что все варианты конкатенации имеют свою производительность. Подробнее о производительности мы поговорим в рамках последующих статей, в которых будут тестовые задания.
"Что будет если сложить строки?"
Ответ
Ранее мы говорили о том что, строки - это массивы байт. Из этого следует, что при работе со строками (конкатенация и тд) мы будем получать новые строки.
"Как определить количество символов для строки?" или "Какие есть нюансы при итерации по строке?"
Ответ
Исходя из того же знания, что строка это массив байт, взяв базовую функцию len()
от строки мы получим количество байт. Похожее поведение будет при итерации по строке - итерация по байтам. Тогда как в зависимости от кодировки, символ в строке может занимать не один байт.
Для того, чтобы работать именно с символами, необходимо преобразовать строку в тип []rune
.
Еще одним способом определения длинны строки является функция RuneCountInString
пакета utf8
.
func main() {
str := "世界, 你好!"
fmt.Printf("len bytes: %d\n", len(str))
fmt.Printf("len runes: %d\n", len([]rune(str)))
fmt.Printf("len runes: %d\n", utf8.RuneCountInString(str))
}
Вывод
len bytes: 15
len runes: 7
len runes: 7
Int в Golang
"Какие численные типы есть в go?"
Ответ
Тут достаточно перечислить:
int/int8/int16/int32/int64;
uint/uint8/uint16/uint32/uint64;
float32/float64;
complex64/complex128;
rune(int32).
"Чем отличается int от uint?"
Ответ
int
содержит диапазон от отрицательных значений до положительных, тогда как uint
- это диапазон от 0 в строну увеличения положительных значений.
Пример: int64
это диапазон от –9 223 372 036 854 775 808
до 9 223 372 036 854 775 807
, uint64
от 0
до 18 446 744 073 709 551 615
.
"Что такое обычный int и какие есть нюансы его реализации?"
Ответ
В зависимости от того какая архитектура платформы, на которой мы стартуем, компилятор преобразует int в int32 для 32 разрядной архитектуры и в int64 для 64 разрядной архитектуры.
"Как преобразовать строку в int и наоборот? Можно ли сделать int(string) и string(int) соответственно?"
Ответ
Преобразование типов между int и string указанным синтаксисом невозможно. Для преобразования необходимо использовать функции из пакета strconv стандартной библиотеки go.
При этом для преобразования строк в/из int
и int64
используются разные функции, strconv.Atoi
и strconv.Itoa
для int
, strconv.ParseInt
и strconv.FormatInt
соответственно.
"Сколько в памяти занимают реализации int32 и int64?"
Ответ
Из самого названия типа следует, что int32
занимает 4 байта (32/8), int64
занимает 8 байтов (64/8).
"Какие предельные значения int32 и int64?"
Ответ
В предыдущем вопросе мы отвечали сколько места в памяти занимают эти типы. Таким образом с помощью 4 или 8 байт можно закодировать разные по диапазону значения.
Для int64
это диапазон от –9 223 372 036 854 775 808
до 9 223 372 036 854 775 807
, для int32
от –2 147 483 648
до 2 147 483 647
.
"Какой результат получим если разделить int на 0 и float на 0?"
Ответ
Это вопрос с подвохом. Деление int
на 0 в go невозможно и вызовет ошибку компилятора. Тогда как деление float
на 0 дает в своем результате бесконечность.
func main() {
f := 500.0
fmt.Printf("float: %v\n", f/0)
}
Вывод
float: +Inf
func main() {
i := 500
fmt.Printf("int: %v\n", i/0)
}
Вывод
division by zero
Const в Golang
"Что такое константы и можно ли их изменять?"
Ответ
Константы - это неизменяемые переменные, изменить константу нельзя.
"Что такое iota?"
Ответ
iota
- идентификатор, который позволяет создавать последовательные не типизированные целочисленные константы. Значением iota
является индекс ConstSpec
. Не смотря на то, что первым индексом является 0, значение первой константы можно задать отличным от 0, что в свою очередь повлияет на значения последующих констант.
Array и slice в Golang
Самый популярный вопрос на собеседовании на любую позицию go инженера - "Что такое слайс и чем он отличается от массива?".
Ответ
Cлайс - это структура go, которая включает в себя ссылку на базовый массив, а также две переменные len(length) и cap(capacity).
len
это длина слайса - то количество элементов, которое в нём сейчас находится.
cap
- это ёмкость слайса - то количество элементов, которые мы можем записать в слайс сверх len
без его дальнейшего расширения.
Array - это последовательно выделенная область памяти. Частью типа array является его размер, который в том числе является не изменяемым.
type slice struct {
array unsafe.Pointer
len int
cap int
}
"Как работает базовая функция append для go?"
Ответ
Функция принимает на вход слайс и переменное количество элементов для добавления в слайс. Append расширяет слайс за пределы его len
, возвращая при этом новый слайс.
Если количество элементов, которые мы добавляем в слайс, не будет превышать cap
, вернется новый слайс, который ссылается на тот же базовый массив, что и предыдущий слайс. Если количество добавляемых элементов превысит cap
, то вернется новый слайс, базовым для которого будет новый массив.
func append(slice []Type, elems ...Type) []Type
"Какой размер массива выделяется под слайс при его расширении за рамки его емкости?"
Ответ
Если отвечать на вопрос поверхностно, то можно сказать, что базовый массив расширяется в два раза от нашей capacity.
Отвечая более емко, следует учесть, что при больших значениях расширение будет не в два раза и будет вычисляться по специальной формуле.
Если развернуть ответ полностью, то это будет звучать примерно так:
если требуемая
cap
больше чем вдвое исходнойcap
, то новаяcap
будет равна требуемой;если это условие не выполнено, а также
len
текущего слайса меньше1024
, то новаяcap
будет в два раза больше базовойcap
;если первое и второе условия не выполнены, то емкость будет увеличиваться в цикле на четверть от базовой емкости пока не будет обработано переполнение. Посмотреть эти условия более подробно можно в исходниках go.
func growslice(et *_type, old slice, cap int) slice {
...
if cap < old.cap {
panic(errorString("growslice: cap out of range"))
}
if et.size == 0 {
// append should not create a slice with nil pointer but non-zero len.
// We assume that append doesn't need to preserve old.array in this case.
return slice{unsafe.Pointer(&zerobase), old.len, cap}
}
newcap := old.cap
doublecap := newcap + newcap
if cap > doublecap {
newcap = cap
} else {
if old.cap < 1024 {
newcap = doublecap
} else {
// Check 0 < newcap to detect overflow
// and prevent an infinite loop.
for 0 < newcap && newcap < cap {
newcap += newcap / 4
}
// Set newcap to the requested cap when
// the newcap calculation overflowed.
if newcap <= 0 {
newcap = cap
}
}
}
...
}
Map в Golang
"Как реализована map(карта) go?"
Ответ
Сама map
в go - это структура, реализующая операции хеширования. При этом, так же как и любую структуру, содержащую ссылки на области памяти,map
необходимо инициализировать. map
ссылается на такие элементы как bucket
(в переводе на русский "ведра"). Каждый bucket
содержит в себе:
8 экстра бит, с помощью которых осуществляется доступ до значений в этом
bucket
;ссылку на следующий коллизионный
bucket
;8 пар ключ-значение, уложенных в массив.
// A header for a Go map.
type hmap struct {
// Note: the format of the hmap is also encoded in cmd/compile/internal/reflectdata/reflect.go.
// Make sure this stays in sync with the compiler's definition.
count int // # live cells == size of map. Must be first (used by len() builtin)
flags uint8
B uint8 // log_2 of # of buckets (can hold up to loadFactor * 2^B items)
noverflow uint16 // approximate number of overflow buckets; see incrnoverflow for details
hash0 uint32 // hash seed
buckets unsafe.Pointer // array of 2^B Buckets. may be nil if count==0.
oldbuckets unsafe.Pointer // previous bucket array of half the size, non-nil only when growing
nevacuate uintptr // progress counter for evacuation (buckets less than this have been evacuated)
extra *mapextra // optional fields
}
"Почему нельзя брать ссылку на значение, хранящееся по ключу в map?"
Ответ
map
поддерживает процедуру эвакуации. Значения, хранящиеся в определённой ячейки памяти в текущий момент времени, в следующий момент времени уже могут там не храниться.
"Что такое эвакуация, и в каком случае она будет происходить?"
Ответ
Эвакуация - это процесс когда map
переносит свои значения из одной области памяти в другую. Это происходит из-за того что число значений в каждом отдельном bucket
максимально равно 8.
В тот момент времени, когда среднее количество значений в bucket
составляет 6.5, go понимает, что размер map
не удовлетворяет необходимому. Начинается процесс расширения map
.
Следует отметить, что сам процесс эвакуации может происходить некоторое время, на протяжение которого новые и старые данные будут связаны.
"Какие есть особенности синтаксиса получения и записи значений в map?"
Ответ
Получить значение из map
, которую мы предварительно не аллоцировали нельзя, приложение упадет в панику.
Если ключ не найден в map
в ответ мы получим дефолтное значение для типа значений map
. То есть, для строки - это будет пустая строка, для int - 0 и так далее. Для того, чтобы точно понять, что в map
действительно есть значение, хранящееся по переданному ключу, необходимо использовать специальный синтаксис. А именно, возвращать не только само значение, но и булевую переменную, которая показывает удалось-ли получить значение по ключу.
"Как происходит поиск по ключу в map?
Ответ
Настоятельно советую почитать более подробно об этом процессе, а также посмотреть код исходников go. По моей практике, интервьюеру всегда хватало такого ответа (просьба учитывать, что опущено множество важных деталей):
вычисляется хэш от ключа;
с помощью значения хэша и размера
bucket
вычисляется используемый для храненияbucket
;вычисляется дополнительный хэш - это первые 8 бит уже полученного хэша;
в полученном
bucket
последовательно сравнивается каждый из 8 его дополнительных хэшей с дополнительным хэшем ключа;если дополнительные хэши совпали, то получаем ссылку на значение и возвращаем его;
если дополнительные хэши не совпали, и в
bucket
больше нет дополнительных хэшей, алгоритм переходит в следующийbucket
, ссылка на который хранится в текущем;если в текущем
bucket
нет ссылки на следующийbucket
, а значение так и не найдено, возвращается дефолтное значение.
func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
...
hash := t.hasher(key, uintptr(h.hash0))
m := bucketMask(h.B)
b := (*bmap)(add(h.buckets, (hash&m)*uintptr(t.bucketsize)))
if c := h.oldbuckets; c != nil {
if !h.sameSizeGrow() {
// There used to be half as many buckets; mask down one more power of two.
m >>= 1
}
oldb := (*bmap)(add(c, (hash&m)*uintptr(t.bucketsize)))
if !evacuated(oldb) {
b = oldb
}
}
top := tophash(hash)
bucketloop:
for ; b != nil; b = b.overflow(t) {
for i := uintptr(0); i < bucketCnt; i++ {
if b.tophash[i] != top {
if b.tophash[i] == emptyRest {
break bucketloop
}
continue
}
k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
if t.indirectkey() {
k = *((*unsafe.Pointer)(k))
}
if t.key.equal(key, k) {
e := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))
if t.indirectelem() {
e = *((*unsafe.Pointer)(e))
}
return e
}
}
}
return unsafe.Pointer(&zeroVal[0])
}
Интерфейсы в Golang
"Что такое интерфейсы в go?"
Ответ
Интерфейс можно рассматривать, как некое соглашение (контракт), что тот или иной объект будет реализовывать указанное в интерфейсе поведение.
Переводя на более человеческий язык, интерфейс - это структура, в которой описаны методы, которые должны быть реализованы для других структур, которые будут удовлетворять этому интерфейсу. Удовлетворение интерфейсу поддерживается на неявном уровне. То есть для объекта достаточно описать реализацию методов интерфейса. И объект, без дополнительных объявлений в кодовой базе, начинает удовлетворять этому интерфейсу.
"Дайте пример реализации интерфейсов."
Ответ
Пример реализации интерфейсов, который я обычно озвучиваю, - это пример с геометрическими фигурами и их площадью.
Перед нами поставили задачу посчитать площадь неравного участка земли. Самый простой способ - это поделить участок на соответствующие фигуры, высчитать площадь каждой фигуры сложить все площади.
Допустим, участок делится на 2 геометрические фигуры: квадрат и прямоугольный треугольник. Для каждой фигуры мы можем создать тип. Описать интерфейс Squarer
, условием реализации которого будет метод расчета площади. Написать для каждого типа метод расчета площади, который будет реализовывать объявленный интерфейс Squarer
.
После этого мы можем написать функцию которая, будет принимать на вход любой из типов, реализующих интерфейс площади, считать площадь каждого и складывать ее в общую сумму.
type Squarer interface {
GetSquare() int
}
type Foursquare struct {
a int
}
func (obj Foursquare) GetSquare() int {
return obj.a * obj.a
}
type Triangle struct {
a int // катет
b int // катет
c int // гипотенуза
}
func (obj Triangle) GetSquare() int {
return obj.a * obj.b / 2
}
func sumSquare(s []Squarer) int {
square := 0
for i := range s {
square += s[i].GetSquare()
}
return square
}
func main() {
figures := []Squarer{Foursquare{a: 3}, Foursquare{a: 2}, Triangle{a: 2, b: 3}}
fmt.Println(sumSquare(figures))
}
Вывод
16
"Что такое пустой интерфейс?"
Ответ
Исходя из определения интерфейса, пустой интерфейс - это интерфейс, для реализации которого не нужно описывать ни одного метода. Таким образом, пустому интерфейсу соответствует абсолютно любой тип.
"Что такое nil интерфейс?"
Ответ
Интерфейс реализован в go, как структура, которая содержит в себе ссылку на само значение и ссылку на структуру itab
. itab
предоставляет служебную информацию об интерфейсе и базовом типе.
Когда интерфейс nil
значит, что интерфейс не ссылается на какое либо значение, но при этом содержит в себе служебную информацию поля itab
. По этой причине булево сравнение nil
с интерфейсом всегда ложное.
type iface struct {
tab *itab
data unsafe.Pointer
}
func main() {
var (
inter interface{}
param *int
)
fmt.Printf("%T %v %v\n", inter, inter, inter == nil)
fmt.Printf("%T %v %v\n", param, param, param == nil)
inter = param
fmt.Printf("%T %v %v\n", inter, inter, inter == nil)
}
Вывод
<nil> <nil> true
*int <nil> true
*int <nil> false
"Как преобразовать интерфейс к другому типу?"
Ответ
Интерфейс можно преобразовать в базовый тип значения (скастить). Для этого используется синтаксис, возвращающий две переменные, одна из которых булевая.
В случае, если не удалось скастить интерфейс, булевая переменная будет ложной, а переменная базового типа, к которому приводим интерфейс будет равна дефолтному значению этого типа.
var (
v string
ok bool
)
v, ok = i.(T)
"Как определить тип интерфейса?"
Ответ
С помощью инструкции switch case
можно определить тип интерфейса, указав возможные варианты базового типа его значения.
func do(i interface{}) {
switch v := i.(type) {
case int:
fmt.Printf("Twice %v is %v\n", v, v*2)
case string:
fmt.Printf("%q is %v bytes long\n", v, len(v))
default:
fmt.Printf("I don't know about type %T!\n", v)
}
}
func main() {
do(21)
do("hello")
do(true)
}
Вывод
Twice 21 is 42
"hello" is 5 bytes long
I don't know about type bool!
Инструкция defer
"Зачем используется ключевое слово defer в go?"
Ответ
Ключевое слово defer
используется для отложенного вызова функции. При этом, место объявления одной инструкции defer
в коде никак не влияет на то, когда та выполнится.
Функция с defer
всегда выполняется перед выходом из внешней функции, в которой defer
объявлялась.
"Каков порядок возврата при использовании несколько функций с defer в рамках одной внешней функции?"
Ответ
defer
добавляет переданную после него функцию в стэк. При возврате внешней функции, вызываются все, добавленные в стэк вызовы. Поскольку стэк работает по принципу LIFO (last in first out), значения стэка возвращаются в порядке от последнего к первому.
Таким образом функции c defer
будут вызываться в обратной последовательности от их объявления во внешней функции. На этот вопрос любят давать практические задачи.
func main() {
fmt.Println("counting")
for i := 1; i < 4; i++ {
defer fmt.Println(i)
}
fmt.Println("done")
}
Вывод
counting
done
3
2
1
"Как передаются значения в функции, перед которыми указано ключевое слово defer?"
Ответ
Аргументы функций, перед которыми указано ключевое слово defer
оцениваются немедленно. То есть на тот момент, когда переданы в функцию.
func main() {
nums := 1 << 5 // 32
defer fmt.Println(nums)
nums = nums >> 1 //16
fmt.Println("done")
}
Вывод
done
32
Заключение
Включить все, даже теоретические вопросы, в одну статью не представляется возможным из-за объемов. Поэтому уже в следующих статьях цикла мы затронем:
конкурентность;
каналы;
контексты;
сборщик мусора;
эскейп анализ и т.д.
Отдельно, хочется выделить, что данная статья написана не для того, чтобы помочь вам проходить интервью, зазубрив ответы на вопросы. Помните, заучив ответ на самый сложный вопрос, вы не приблизитесь к пониманию этого ответа. И рано или поздно вы все равно споткнетесь о свое незнание.
Поэтому, я надеюсь, что все изложенное здесь будет использоваться: или как чеклист для прохождения интервью, или обратит ваше внимание на ваши же слабые места.
В любом случае, по каждому вопросу я настоятельно советую почитать более глубокую литературу, посмотреть доклады, поковыряться в исходниках go. Цели подробно разжевать все тонкости я перед собой не ставил.
Так как не претендую на звание эксперта в go, прошу по существу писать замечания и предложения в комментариях. Постараюсь их учесть и сделать материал наиболее полезным. Спасибо!
Благодарности
Спасибо за конструктивные замечания к статье как до, так и после ее выкладки: @user862, @JimTheBeam, @bogolt, @cadovvl,@eandr_67, @george3