Вопросы и ответы с собеседований по Go
Вопросы и ответы с собеседований Go-разработчиков. Типы данных, рефлексия, парадигма ООП, атомарные операции, горутины, особенность работы с каналами, операции со строками, эвакуация, работа с интерфейсами, generics и многое другое.
Какие типы данных используются в Go?
Go работает со следующими типами:
- Method
(метод);
- Boolean
(логический тип);
- Numeric
(численный);
- String
(строковый);
- Array
(массив);
- Slice
(срез);
- Struct
(структура);
- Pointer
(указатель);
- Function
(функция);
- Interface
(интерфейс);
- Map
(карта);
- Channel
(канал).
Как проверить тип переменной в среде выполнения?
Лучшим способом проверки типа переменной во время выполнения является Type Switch
. Он анализирует переменные по типу, а не значению.
Например:
package main
import "fmt"
func do(i interface{}) {
switch v := i.(type) {
case int:
fmt.Printf("Double %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 type %T!\n", v)
}
}
func main() {
do(21)
do("hello")
do(true)
}
Также можно это сделать с помощью рефлексии:
package main
import (
"fmt"
"reflect"
)
func main() {
x := 42
fmt.Println("Тип переменной x:", reflect.TypeOf(x))
}
Что такое рефлексия в Go?
Рефлексия в Go реализована в пакете reflect
и представляет собой механизм, позволяющий коду исследовать значения, типы и структуры во время выполнения, без заранее известной информации о них.
Рефлексия полезна в ситуациях, когда нам нужно работать с данными неизвестного типа, например, при сериализации/десериализации данных, реализации ORM систем и так далее.
С помощью рефлексии мы можем, например, определить тип переменной, прочитать и изменить её значения, вызвать методы динамически. Это делает код более гибким, но следует использовать рефлексию осторожно, так как она может привести к сложному и трудночитаемому коду, а также снизить производительность.
Простые примеры:
Определение типа переменной:
package main
import (
"fmt"
"reflect"
)
func main() {
x := 42
fmt.Println("Тип переменной x:", reflect.TypeOf(x))
}
В примере мы используем функцию reflect.TypeOf()
, чтобы определить тип переменной x
. Программа выведет int
, так как x
— целое число.
Чтение и изменение значений:
package main
import (
"fmt"
"reflect"
)
func main() {
x := 42
v := reflect.ValueOf(&x).Elem() // Получаем reflect.Value
fmt.Println("Исходное значение x:", x)
v.SetInt(43) // Изменяем значение x
fmt.Println("Новое значение x:", x)
}
Здесь мы используем reflect.ValueOf()
для получения reflect.Value
переменной x
, а затем изменяем её значение с помощью SetInt()
.
Динамический вызов методов:
package main
import (
"fmt"
"reflect"
)
type MyStruct struct {
Field int
}
func (m *MyStruct) UpdateField(val int) {
m.Field = val
}
func main() {
x := MyStruct{Field: 10}
// Получаем reflect.Value структуры
v := reflect.ValueOf(&x)
// Получаем метод по имени
method := v.MethodByName("UpdateField")
// Вызываем метод с аргументами
method.Call([]reflect.Value{reflect.ValueOf(20)})
fmt.Println("Обновленное значение поля:", x.Field)
}
В этом примере мы создаем экземпляр структуры MyStruct
, получаем метод UpdateField
с помощью MethodByName
и вызываем его динамически с помощью Call
. Метод обновляет значение поля структуры.
Является ли Go объектно-ориентированным языком?
Да и нет. Хотя в Go есть типы и методы, и он допускает объектно-ориентированный стиль программирования, в нем нет иерархии типов. Концепция «интерфейс» в Go предоставляет другой подход, считающийся простым в использовании и в некотором роде более общим. Есть также способы встраивать типы в другие типы, чтобы обеспечить нечто подобное, но не идентичное подклассам. Более того, методы в Go более общие, чем в C++ или Java: они могут быть определены для любого типа данных, даже для встроенных, таких как простые "unboxed" (представляющие значения) целые числа. Они не ограничиваются структурами (классами). Кроме того, отсутствие иерархии типов делает "объекты" в Go намного легче, чем в таких языках, как C++ или Java.
Как в Go реализовано наследование?
Как такового наследования в Go нет, но при этом у нас есть структуры - это специальные типы, в которые мы можем включать другие типы, в том числе такие же структуры. Можно сказать, что Go предпочитает наследованию композицию.
Как в Go реализована инкапсуляция?
Инкапсуляция в Go - это возможность задавать переменным, функциям и методам первую букву названия в верхнем или нижнем регистре. Соответственно нижний регистр будет значить, что переменная, функция или метод доступна только в рамках пакета. Тогда как верхний регистр даст доступ к переменной, функции или методу за рамками пакета.
Как в Go реализован полиморфизм?
Полиморфизм в Go реализован с помощью интерфейсов. Основная идея заключается в том, что мы можем объявить интерфейсы (контракты на определённое поведение) для наших типов. При этом, для типов мы должны реализовать методы, удовлетворяющие этим интерфейсам. Таким образом, мы сможем работать со всем набором типов, у которых реализовали интерфейсы, как с единым интерфейсным типом.
Какие механизмы синхронизации доступны в Go?
В Go примитивы синхронизации — это инструменты из пакета sync
(и не только), которые помогают нам гарантировать, что множество горутин может безопасно взаимодействовать с общими данными или координировать свою работу.
sync.Mutex
: основной примитив блокировки для исключения одновременного доступа к данным. Мьютексы позволяют только одной горутине получить доступ к общему ресурсу в определенный момент времени.
sync.RWMutex
: разрешает множественное чтение или одну операцию записи в текущий момент времени.
sync.WaitGroup
: используется для ожидания завершения группы горутин перед продолжением выполнения основной программы.
sync.Once
: гарантирует, что функция будет вызвана только один раз, несмотря на количество вызовов.
sync.Cond
: предоставляет механизм для блокирования горутины, пока не будет выполнено некоторое условие. Не так давно Расс Кокс отменил предложение удалить данные тип в будущей версии Go.
Подобную роль играют:
Каналы. Каналы в Go хоть и не являются примитивами синхронизации в традиционном понимании, они играют ключевую роль в управлении горутинами, позволяют обеспечить безопасный обмен данными между ними. Каналы обеспечивают синхронизацию и блокируют выполнение до тех пор, пока данные не будут переданы или приняты.
Атомарные операции: Golang предоставляет атомарные операции для безопасного выполнения операций чтения и записи разделяемых данных.
Что такое атомарная операция?
Атомарная операция выполняется за один шаг относительно других потоков или, в контексте Go, других горутин. Это означает, что атомарную операцию нельзя прервать в середине ее работы.
Стандартная библиотека Go содержит пакет atomic
, который в некоторых простых случаях может помочь избежать использования мьютекса. С помощью него мы получаем доступ к атомарным счетчикам из нескольких горутин, не имея проблем с синхронизацией и не беспокоясь о race condition.
Что такое горутина? Как ее остановить?
Горутина — это функция или метод, которые выполняются конкурентно с любыми другими горутинами, используя специальный поток. Потоки горутин более легковесны, чем стандартные потоки, и большинство программ Go одновременно используют тысячи горутин.
Для создания горутины перед объявлением функции нужно добавить ключевое слово go
.
go f(x, y, z)
Остановить горутину можно отправкой сигнала в специальный канал. При этом горутины могут отвечать на такие сигналы, только если им сказано выполнять проверку. Поэтому нужно будет включить проверки в подходящие места, например в начало цикла for
.
package main
func main() {
quit := make(chan bool)
go func() {
for {
select {
case <-quit:
return
default:
// …
}
}
}()
// …
quit <- true
}
Что такое канал и какие виды каналов есть в Go?
Каналы — это инструменты коммуникации между горутинами.
Технически это конвейер/труба, откуда можно считывать или помещать данные. То есть одна горутина может отправить данные в канал, а другая — считать помещенные в этот канал данные.
Для создания канала в Go есть ключевое слово chan
. Канал может передавать данные только одного типа.
package main
import "fmt"
func main() {
var c chan int
fmt.Println(c)
}
При простом определении переменной канала она имеет значение nil
, то есть по сути канал неинициализирован. Для инициализации применяется функция make()
.
В зависимости от определения емкости канала он может быть буферизированным или небуферизированным.
Для создания небуферизированного канала вызывается функция make()
без указания емкости канала:
var intCh chan int = make(chan int)
Буферизированные каналы также создаются с помощью функции make()
, только в качестве второго аргумента в функцию передается емкость канала. Если канал пуст, то получатель ждет, пока в канале появится хотя бы один элемент.
chanBuf := make(chan bool, 3)
С каналом можно произвести 4 действия:
- создать канал
- записать данные в канал
- вычесть что-то из канала
- закрыть канал
Однонаправленные каналы: в Go можно определить канал, как доступный только для отправки данных или только для получения данных.
Канал может быть возвращаемым значением функции. Однако, следует внимательно подходить к операциям записи и чтения в возвращаемом канале.
Как работают буферизованные и небуферизованные каналы?
Буферизованные каналы позволяют вам быстро помещать задания в очередь, чтобы вы могли работать с большим количеством запросов и обрабатывать их позже. Кроме того, буферизованные каналы можно
использовать в качестве семафоров, ограничивая пропускную способность вашего приложения.
Суть: все входящие запросы перенаправляются на канал, который обрабатывает их по очереди. Завершая обработку запроса, канал отправляет исходному, вызвавшему сообщение о готовности обработать новый запрос. Таким образом, ёмкость буфера канала ограничивает количество одновременных запросов, которые он может хранить.
Вот так выглядит код, который реализует данный метод:
package main
import (
"fmt"
)
func main() {
numbers := make(chan int, 5)
// канал numbers не может хранить более пяти целых чисел — это буферный канал с емкостью 5
counter := 10
for i := 0; i < counter; i++ {
select {
// здесь происходит обработка
case numbers <- i * i:
fmt.Println("About to process", i)
default:
fmt.Print("No space for ", i, " ")
}
// мы начинаем помещать данные в numbers, однако когда канал заполнен, он перестанет сохранять данные и будет выполняться ветка default
}
fmt.Println()
for {
select {
case num := <-numbers:
fmt.Print("*", num, " ")
default:
fmt.Println("Nothing left to read!")
return
}
}
}
Аналогично, мы пытаемся считывать данные из numbers
, используя цикл for
. Когда все данные из канала считаны, выполнится ветка default
и программа завершится с помощью оператора return
.
При выполнении кода выше мы получаем такой вывод:
$ go run bufChannel.go
About to process 0
. . .
About to process 4
No space for 5 No space for 6 No space for 7 No space for 8 No space
for 9
*0 *1 *4 *9 *16 Nothing left to read!
В общем:
Буферизированный канал заблокирует горутину только в том случае, если весь буфер забит. И происходит попытка еще одной записи. Как только будет выполнено чтение из канала - горутина разблокируется. В случае, если горутина всего одна (только функция main) и канал её заблокирует — программа выпадет с ошибкой, так как все горутины блокированы и выполнять нечего.
Небуферизированный канал заблокирует горутину до момента, пока с него ничего не прочитают.
Можно ли в Go закрыть канал со стороны читателя?
Закрытие канала обычно выполняется отправителем, а не получателем. Это связано с тем, что закрытие канала со стороны получателя может привести к панике при попытке отправителя записать в уже закрытый канал.
Однако, в некоторых случаях, получатель может определить, что данные больше не нужны, и хочет уведомить отправителя о прекращении отправки. В таком случае, обычно используется дополнительный канал, называемый каналом управления или сигнальным каналом, который получатель может использовать для отправки сигнала об остановке. После получения сигнала, отправитель может корректно закрыть основной канал данных.
Простой пример:
func main() {
dataCh := make(chan int)
stopCh := make(chan struct{})
go func() {
for {
select {
case data, ok := <-dataCh:
if !ok {
// Канал закрыт, прекращаем обработку
return
}
// Обработка данных
fmt.Println(data)
case <-stopCh:
// Получен сигнал остановки, закрываем канал dataCh
close(dataCh)
return
}
}
}()
// Отправка данных в канал
dataCh <- 1
dataCh <- 2
// Отправка сигнала остановки
stopCh <- struct{}{}
}
stopCh
используется для уведомления горутины о необходимости закрыть канал dataCh
. Это безопасный способ обеспечить корректное управление жизненным циклом канала.
Что такое пакеты в Go?
Пакет - это механизм переиспользования кода, при котором Go файлы помещаются в общую директорию. В начале каждого такого файла объявляется зарезервированное слово package
, а после него прописывается имя пакета. В рамках пакета все функции и глобальные переменные, объявленные как в верхнем, так и в нижнем регистре, видят друг друга.
Как работает управление памятью в Go?
Go использует сборщик мусора для автоматического управления памятью. Разработчику не нужно явно выделять и освобождать память, как в языках типа C или C++. Однако нужно быть внимательным при работе с большими структурами данных, чтобы избежать утечек памяти. Некоторые ключевые аспекты управления памятью в Go: - Go применяет алгоритм сборки мусора с маркировкой и освобождением. Сборщик мусора отмечает активные объекты, после чего освобождает память от неактивных. - В Go можно работать с указателями, но нет прямого управления выделением и освобождением памяти через них. Память выделяется при создании объектов и автоматически освобождается сборщиком мусора. - Хотя Go управляет памятью автоматически, неправильное использование, например, из-за циклических ссылок, может вызвать утечки памяти. Поэтому важно контролировать использование ресурсов. - Срезы в Go — это динамические массивы, обеспечивающие автоматическое управление памятью при изменении их размера. - Go разделяет память на стек и кучу. Стек — для локальных переменных и контекста функций; каждый поток имеет свой стек. Куча — для долгоживущих объектов и данных, которые могут быть доступны из разных частей программы. Управление памятью в куче осуществляется сборщиком мусора. - Escape analysis в Go определяет, следует ли объекту быть на стеке или в куче, опираясь на его использование в программе. Этот анализ помогает оптимизировать управление памятью, делая его более эффективным.
Что такое глобальная переменная?
Глобальная переменная - это переменная уровня пакета, то есть объявленная вне функции. Глобальная переменная также может быть доступна за рамками пакета, конечно только в том случае, если ее наименование начинается в верхнем регистре.
Что такое heap и stack?
Стек (stack) — это область оперативной памяти, которая создаётся для каждого потока. Он работает в порядке LIFO (Last In, First Out), то есть последний добавленный в стек кусок памяти будет первым в очереди на вывод из стека. Каждый раз, когда функция объявляет новую переменную, она добавляется в стек, а когда эта переменная пропадает из области видимости (например, когда функция заканчивается), она автоматически удаляется из стека. Когда стековая переменная освобождается, эта область памяти становится доступной для других стековых переменных. Стек быстрый, так как часто привязан к кэшу процессора. Размер стека ограничен, и задаётся при создании потока. Куча (heap) — это хранилище памяти, также расположенное в ОЗУ, которое допускает динамическое выделение памяти и не работает по принципу стека: это просто склад для ваших переменных. Когда вы выделяете в куче участок памяти для хранения переменной, к ней можно обратиться не только в потоке, но и во всем приложении. Именно так определяются глобальные переменные. По завершении приложения все выделенные участки памяти освобождаются. Размер кучи задаётся при запуске приложения, но, в отличие от стека, он ограничен лишь физически, и это позволяет создавать динамические переменные. В сравнении со стеком, куча работает медленнее, поскольку переменные разбросаны по памяти, а не сидят на верхушке стека. То что попадает в кучу, живёт там пока не придёт GC. Но почему стек так быстр? Основных причин две: - Стеку не нужно иметь сборщик мусора (garbage collector). Как мы уже упоминали, переменные просто создаются и затем вытесняются, когда функция завершается. Не нужно запускать сложный процесс освобождения памяти от неиспользуемых переменных и т.п. - Стек принадлежит одной горутине, переменные не нужно синхронизировать в сравнении с теми, что находятся в куче. Что также повышает производительность
Что делает runtime.newobject()?
runtime.newobject()
выделяет память в куче.
Что такое {} с необъявленным оператором в Go функции?
В Go функции действительно можно объявить {}
без оператора, ограничив область видимости куска кода в рамках этой функции.
Как выполнить ряд условий в одном операторе switch case?
Такое возможно благодаря ключевому слову fallthrough
. Оно заставляет выполнять код в следующей объявленной булевой секции, вне зависимости от того, подходит ли булевое условие case
этой секции.
Что такое строки в Go?
Строки в Go - это обычный массив байт.
Как можно оперировать строками?
Строки в Go можно складывать (конкатенировать). Для многих операций есть стандартные пакеты, к примеру strings
, fmt
. Все варианты конкатенации имеют свою производительность.
strings.Builder
— рекомендуемое решение для конкатенации списка строк. Обычно это решение следует использовать в циклах.
Если просто нужно объединить несколько строк, использование strings.Builder
не рекомендуется, так как это сделает код менее читаемым, чем использование оператора +=
или fmt.Sprintf
.
Как узнать длину строки?
Исходя из знания, что строка - это массив байт, взяв базовую функцию len()
, от строки мы получим количество байт. Похожее поведение будет при итерации по строке - итерация по байтам. Тогда как в зависимости от кодировки, символ в строке может занимать не один байт.
Для того, чтобы работать именно с символами, необходимо преобразовать строку в тип []rune
.
Еще одним способом определения длины строки является функция RuneCountInString
пакета utf8
.
Какие численные типы есть в Go?
- int
int8
int16
int32
int64
;
- uint
uint8
uint16
uint32
uint64
;
- float32
float64
;
- complex64
complex128
;
- rune(int32)
.
Расскажите про числовые константы в Go
Числовые константы в Go — это фиксированные значения, которые не изменяются во время выполнения программы. Они представлены точными значениями, не имеющими ограничений по размеру или точности, в отличие от переменных. Это означает, что числовые константы могут быть представлены с гораздо большей точностью, чем обычные числовые переменные.
Они принимают свой тип (например, int, float64) только когда это необходимо, например, при присваивании значения переменной или при использовании в операции, где требуется определённый тип. Это дает гибкость и предотвращает потерю информации из-за ограничений размера типа, особенно при выполнении математических операций с константами.
Простой пример:
package main
import "fmt"
const (
Big = 1 << 100
Small = Big >> 99
)
func needInt(x int) int { return x*10 + 1 }
func needFloat(x float64) float64 { return x * 0.1 }
func main() {
fmt.Println(needInt(Small))
fmt.Println(needFloat(Small))
fmt.Println(needFloat(Big))
}
Чем отличается 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 и наоборот?
Для преобразования необходимо использовать функции из пакета strconv
стандартной библиотеки Go.
При этом, для преобразования строк в/из int
и int64
используются разные функции, strconv.Atoi
и strconv.Itoa
для int
, strconv.ParseInt
и strconv.FormatInt
для int64
соответственно.
Сколько в памяти занимают int32 и int64?
Из самого названия типа следует, что int32
занимает 4 байта (32/8), int64
занимает 8 байтов (64/8).
Какие предельные значения int32 и int64?
С помощью 4 (int32
) или 8 (int64
) байт можно закодировать разные по диапазону значения.
Для 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
дает в своем результате бесконечность.
Что такое iota?
iota
- идентификатор, который позволяет создавать последовательные нетипизированные целочисленные константы.
Значением iota
является индекс ConstSpec
. Не смотря на то, что первым индексом является 0
, значение первой константы можно задать отличным от 0
, что в свою очередь повлияет на значения последующих констант.
Что такое слайс и чем он отличается от массива?
Cлайс - это структура Go, которая включает в себя ссылку на базовый массив, а также две переменные len
(length) и cap
(capacity).
len
- это длина слайса - то количество элементов, которое в нём сейчас находится.
cap
- это ёмкость слайса - то количество элементов, которые мы можем записать в слайс сверх len
без его дальнейшего расширения.
Array
- это последовательно выделенная область памяти. Частью типа array является его размер, который в том числе является неизменяемым.
Как работает базовая функция append для Go?
Функция принимает на вход слайс и переменное количество элементов для добавления в слайс. append
расширяет слайс за пределы его len
, возвращая при этом новый слайс.
Если количество элементов, которые мы добавляем в слайс, не будет превышать cap
, вернется новый слайс, который ссылается на тот же базовый массив, что и предыдущий слайс.
Если количество добавляемых элементов превысит cap
, то вернется новый слайс, базовым для которого будет новый массив.
Какой размер массива выделяется под слайс при его расширении за рамки емкости?
Если отвечать на вопрос поверхностно, то можно сказать, что базовый массив расширяется в два раза от нашей capacity
.
Отвечая более ёмко, следует учесть, что при больших значениях расширение будет не в два раза и будет вычисляться по специальной формуле.
Если развернуть ответ полностью, то это будет звучать примерно так:
- если требуемая cap
больше, чем вдвое исходной cap
, то новая cap
будет равна требуемой;
- если это условие не выполнено, а также len
текущего слайса меньше 1024, то новая cap
будет в два раза больше базовой cap
;
- если первое и второе условия не выполнены, то емкость будет увеличиваться в цикле на четверть от базовой емкости, пока не будет обработано переполнение. Посмотреть эти условия более подробно можно в исходниках Go.
Как реализована map (карта) в Go?
Сама map
в Go - это структура, реализующая операции хеширования. При этом, так же, как и любую структуру, содержащую ссылки на области памяти, map
необходимо инициализировать.
map
ссылается на такие элементы как bucket
. Каждый bucket
содержит в себе:
- 8 экстра бит, с помощью которых осуществляется доступ до значений в этом bucket
;
- ссылку на следующий коллизионный bucket
;
- 8 пар ключ-значение, уложенных в массив.
Почему нельзя брать ссылку на значение, хранящееся по ключу в map?
map
поддерживает процедуру эвакуации. Значения, хранящиеся в определённой ячейке памяти в текущий момент времени, в следующий момент времени уже могут там не храниться.
Что такое эвакуация, и когда она происходит?
Эвакуация - это процесс, когда map
переносит свои значения из одной области памяти в другую. Это происходит из-за того, что кол-во значений в каждом отдельном bucket
максимально равно 8.
В тот момент времени, когда среднее количество значений в bucket
составляет 6.5, Go понимает, что размер map
не удовлетворяет необходимому. Начинается процесс расширения map
.
Следует отметить, что сам процесс эвакуации может происходить некоторое время, на протяжение которого новые и старые данные будут связаны.
Какие есть особенности синтаксиса получения и записи значений в map?
Получить значение из map, которую мы предварительно не аллоцировали, нельзя - приложение упадет в панику. Если ключ не найден в map, в ответ мы получим дефолтное значение для типа значений map. То есть, для строки - это будет пустая строка, для int - 0 и так далее. Для того, чтобы точно понять, что в map действительно есть значение, хранящееся по переданному ключу, необходимо использовать специальный синтаксис. А именно, возвращать не только само значение, но и булевую переменную, которая показывает, удалось ли получить значение по ключу.
Как происходит поиск по ключу в map?
1. вычисляется хэш от ключа; 2. с помощью значения хэша и размера bucket вычисляется используемый для хранения bucket; 3. вычисляется дополнительный хэш - это первые 8 бит уже полученного хэша; 4. в полученном bucket последовательно сравнивается каждый из 8 его дополнительных хэшей с дополнительным хэшем ключа; 5. если дополнительные хэши совпали, то получаем ссылку на значение и возвращаем его; 6. если дополнительные хэши не совпали, и в bucket больше нет дополнительных хэшей, алгоритм переходит в следующий bucket, ссылка на который хранится в текущем; 7. если в текущем bucket нет ссылки на следующий bucket, а значение так и не найдено, возвращается дефолтное значение.
Что такое интерфейсы в Go?
Интерфейс можно рассматривать, как некое соглашение (контракт), что тот или иной объект будет реализовывать указанное в интерфейсе поведение. Переводя на более человеческий язык, интерфейс - это структура, в которой описаны методы, которые должны быть реализованы для других структур, которые будут удовлетворять этому интерфейсу. Удовлетворение интерфейсу поддерживается на неявном уровне. То есть для объекта достаточно описать реализацию методов интерфейса. И объект без дополнительных объявлений в кодовой базе начинает удовлетворять этому интерфейсу.
Приведите пример реализации интерфейсов
Перед нами поставили задачу посчитать площадь неравного участка земли. Самый простой способ - это поделить участок на соответствующие фигуры, высчитать площадь каждой фигуры и сложить все площади.
Допустим, участок делится на 2 геометрические фигуры: квадрат и прямоугольный треугольник. Для каждой фигуры мы можем создать тип. Описать интерфейс Squarer
, условием реализации которого будет метод расчета площади. Написать для каждого типа метод расчета площади, который будет реализовывать объявленный интерфейс Squarer
.
После этого мы можем написать функцию которая, будет принимать на вход любой из типов, реализующих интерфейс площади, считать площадь каждого и складывать ее в общую сумму.
Что такое пустой интерфейс?
Исходя из определения интерфейса, пустой интерфейс - это интерфейс, для реализации которого не нужно описывать ни одного метода. Таким образом, пустому интерфейсу соответствует абсолютно любой тип.
Что такое nil интерфейс?
Интерфейс реализован в Go, как структура, которая содержит в себе ссылку на само значение и ссылку на структуру itab
. itab
предоставляет служебную информацию об интерфейсе и базовом типе.
nil
интерфейс не ссылается на какое либо значение, но при этом содержит в себе служебную информацию поля itab
. По этой причине булево сравнение nil
с интерфейсом всегда ложное.
Как преобразовать интерфейс к другому типу?
Интерфейс можно преобразовать в базовый тип значения (скастить). Для этого используется синтаксис, возвращающий две переменные, одна из которых булевая. В случае, если не удалось скастить интерфейс, булевая переменная будет ложной, а переменная базового типа, к которому приводим интерфейс, будет равна дефолтному значению этого типа.
Как определить тип интерфейса?
С помощью инструкции switch case
можно определить тип интерфейса, указав возможные варианты базового типа его значения.
Зачем используется ключевое слово defer в Go?
Ключевое слово defer
используется для отложенного вызова функции. При этом, место объявления одной инструкции defer
в коде никак не влияет на то, когда та выполнится.
Функция с defer
всегда выполняется перед выходом из внешней функции, в которой defer
объявлялась.
Порядок возврата при использовании несколько функций с defer?
func main() {
fmt.Println("counting")
for i := 1; i < 4; i++ {
defer fmt.Println(i)
}
fmt.Println("done")
}
defer
добавляет переданную после него функцию в стек. При возврате внешней функции вызываются все добавленные в стек вызовы. Поскольку стек работает по принципу LIFO (last in first out), значения стека возвращаются в порядке от последнего к первому.
Таким образом, функции c defer
будут вызываться в обратной последовательности от их объявления во внешней функции.
Как передаются значения в функции, перед которыми указано defer?
func main() {
nums := 1 << 5 // 32
defer fmt.Println(nums)
nums = nums >> 1 //16
fmt.Println("done")
}
Аргументы функций, перед которыми указано ключевое слово defer
оцениваются немедленно. То есть на тот момент, когда переданы в функцию.
Что такое замыкания функций?
Замыкание функции — это значение функции, ссылающееся на переменные вне ее тела. Такая функция может обращаться к этим переменным и присваивать им значения.
Например, adder()
возвращает замыкание, привязанное к собственной переменной sum
, на которую оно ссылается.
package main
import "fmt"
func adder() func(int) int {
sum := 0
return func(x int) int {
sum += x
return sum
}
}
func main() {
pos, neg := adder(), adder()
for i := 0; i < 10; i++ {
fmt.Println(
pos(i),
neg(-2*i),
)
}
}
Что такое lock-free структуры данных?
Lock-free структуры данных — это тип структур данных, разработанных для многопоточных операций без использования традиционных блокировок, таких как мьютексы.
Основная идея заключается в том, чтобы обеспечить безопасность потоков и избежать проблем, связанных с блокировками, включая взаимную блокировку (deadlock) и узкие места производительности (bottlenecks).
Lock-free структуры данных обычно используют атомарные операции, такие как CAS (compare-and-swap), для обеспечения согласованности данных между потоками. Эти операции позволяют потокам соревноваться за изменение данных, но гарантируют, что только один поток сможет успешно изменить данные в любой момент времени.
В Go, языке с поддержкой конкурентности, есть несколько примеров lock-free или почти lock-free структур данных, особенно в стандартной библиотеке.
Например:
Каналы: хотя каналы в Go не являются полностью lock-free, они предоставляют высокоуровневый способ обмена данными между горутинами без явного использования блокировок.
Атомарные операции: пакет sync/atomic
в Go предоставляет примитивы для атомарных операций, которые являются ключевыми компонентами для создания lock-free структур данных.
sync.Map
: предназначен для использования в кейсах, где ключи в основном не меняются, и он использует оптимизации для уменьшения необходимости блокировок.
Как устроен сетевой ввод-вывод в Go?
Сетевой ввод-вывод в Go организован через пакет net
стандартной библиотеки, который предоставляет обширный API для работы с сетью. Он использует модель неблокирующего ввода-вывода с горутинами для обеспечения масштабируемости и эффективности.
Когда мы создаем сетевое соединение или слушаем порт, каждая операция ввода-вывода (например, чтение или запись данных) может выполняться в отдельной горутине, позволяя обрабатывать множество соединений параллельно без блокировки главного потока выполнения.
Go автоматически управляет множеством горутин, что упрощает написание масштабируемого асинхронного сетевого кода по сравнению с традиционными подходами, основанными на потоках.
Вот простой пример, из него должно быть всё понятно:
package main
import (
"fmt"
"io"
"net"
"os"
)
func main() {
// Слушаем на порту 8080
listener, err := net.Listen("tcp", ":8080")
if err != nil {
fmt.Println("Ошибка при создании слушателя:", err)
os.Exit(1)
}
defer listener.Close()
fmt.Println("Сервер запущен и слушает на порту 8080")
for {
// Принимаем входящее подключение
conn, err := listener.Accept()
if err != nil {
fmt.Println("Ошибка при принятии подключения:", err)
continue
}
// Обработка подключения в отдельной горутине
go handleConnection(conn)
}
}
// handleConnection обрабатывает отдельное подключение
func handleConnection(conn net.Conn) {
defer conn.Close()
fmt.Println("Подключился клиент:", conn.RemoteAddr().String())
// Отправляем сообщение клиенту
_, err := io.WriteString(conn, "Привет от сервера!\n")
if err != nil {
fmt.Println("Ошибка при отправке сообщения:", err)
return
}
fmt.Println("Сообщение отправлено клиенту:", conn.RemoteAddr().String())
}
Что такое дженерики (обобщения)?
Дженерики или обобщения — это средства языка, позволяющего работать с различными типами данных без изменения их описания.
В версии 1.18 появились дженерики (вообще-то они были и ранее, но мы не могли их использовать в своём коде — вспомним функцию make(T type)
).
Дженерики позволяют объявлять (описывать) универсальные методы, т.е. в качестве параметров и возвращаемых значений указывать не один тип, а их наборы.
Появились новые ключевые слова:
- any
— аналог interface{}
, можно использовать в любом месте
(func do(v any) any, var v any, type foo interface { Do() any })
- comparable
— интерфейс, который определяет типы, которые могут быть сравнены с помощью ==
и !=
(переменные такого типа создать нельзя — var j comparable
будет вызывать ошибку)
Для чего нужна функция recover()?
Панику можно обработать внутри отложенной функции и восстановить нормальное выполнение программы. Для этого предназначена глобальная функция recover(). Формат функции:
recover() interface{}
Если возникла паника, то функция вернет объект ошибки, указанный в функции panic()
. Если паника не возникла, то возвращается значение nil
. Вызывать функцию recover()
нужно внутри отложенной функции (функции, зарегистрированной с помощью инструкции defer
). После вызова функции recover()
считается, что паника обработана и можно продолжить выполнение программы.
Вот пример обработки деления на 0:
package main
import "fmt"
func main() {
fmt.Println(division(10, 2))
fmt.Println(division(10, 0))
fmt.Println("Выполнение программы продолжается!")
}
func division(x, y int) (n int) {
defer func() {
if r := recover(); r != nil {
fmt.Println(r)
n = 0 // Возвращаем из функции division() ноль
}
}()
fmt.Println("Инструкция до деления")
n = x / y
fmt.Println("Инструкция после деления")
return
}
// Инструкция до деления
// Инструкция после деления
// 5
// Инструкция до деления
// runtime error: integer divide by zero
// 0
// Выполнение программы продолжается!