Go. Прорабатываем 25 основных вопросов собеседования

Go, иначе называемый Golang, является открытым языком программирования, который похож на C, но при этом оптимизирован для более быстрой компиляции, удобной реализации конкурентности и общей простоты использования. 

Разрабатывался и изначально применялся этот язык в Google. Тем не менее за несколько последних лет ввиду увеличения спроса на многопоточные сетевые программы его начали осваивать и другие компании. 

Независимо от того, готовитесь ли вы к собеседованию в Google или просто хотите сохранять передовые навыки разработки, Go окажется удачным выбором. Сегодня мы попрактикуемся в работе с этим зыком на примере 25 вероятных вопросов для собеседования и ответов на них.

План будет таков:

  • вопросы по основам Go;
  • вопросы среднего уровня;
  • задачи по программированию на Go;
  • вопросы, касающиеся конкурентности;
  • дальнейшие шаги обучения.

Вопросы по основам Go

1. В чем преимущество Go перед другими языками?

  • В отличие от других языков, которые зарождались в качестве академических экспериментов, в Go код спроектирован прагматично. Каждая из его возможностей и выбор синтаксиса разработаны с учетом удобства для программиста.
  • Go оптимизирован под конкурентность и отлично поддается масштабированию.
  • Go зачастую признается более удобочитаемым, чем его альтернативы, ввиду единого стандартного формата кода.
  • Автоматическая сборка мусора в нем заметно эффективнее, чем в Java или Python, так как выполняется параллельно с программой.

2. Что такое строковые литералы?

Строковый литерал  —  это строковая константа, сформированная конкатенацией символов. Представлены они могут быть в двух видах: необработанными (сырыми) или интерпретированными строковыми литералами.

Сырые строковые литералы пишутся в левых кавычках (foo) и заполняются не интерпретированными символами UTF-8. Интерпретированные же представляют то, что мы обычно называем строками, которые записываются в двойные кавычки и содержат любой символ, за исключением разрыва строки и не закрытых двойных кавычек.

3. Какие типы данных используются в Go?

Go работает со следующими типами:

  • Method (метод);
  • Boolean (логический тип);
  • Numeric (численный);
  • String (строковый);
  • Array (массив);
  • Slice (срез);
  • Struct (структура);
  • Pointer (указатель);
  • Function (функция);
  • Interface (интерфейс);
  • Map (карта);
  • Channel (канал).

4. Что такое пакеты в программе Go?

Пакеты (pkg)  —  это каталоги в рабочем пространстве Go, где содержатся исходные файлы или другие пакеты. Каждая функция, переменная и тип из исходных файлов хранятся в связанном с ними пакете. Каждый исходный файл Go принадлежит пакету, который объявляется в начале этого файла:

package <packagename>

Можно импортировать и экспортировать пакеты, чтобы повторно использовать экспортированные функции или типы с помощью:

import <packagename>

Стандартный пакет Go  —  это fmt. Он содержит функциональность форматирования и вывода вроде Println().

5. Какую форму преобразования типов поддерживает Go? Преобразуйте целое число в число с плавающей запятой.

Go поддерживает явные преобразования типов, соответствуя требованиям строгой типизации.

i := 55      //int

j := 67.8    //float64

sum := i + int(j) //j преобразуется в int

6. Что такое горутина? Как ее остановить?

Горутина  —  это функция или метод, которые выполняются конкурентно с любыми другими горутинами, используя специальный поток. Потоки горутин более легковесны, чем стандартные потоки, и большинство программ 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
}

7. Как проверить тип переменной в среде выполнения?

Лучшим способом проверки типа переменной при выполнении является Type Switch (переключатель типов). Переключатель типов оценивает переменные по типу, а не значению. Каждый такой переключатель содержит не менее одного case, который выступает в роли инструкции условия, а также кейс default, которые выполняется, если ни один из кейсов не верен.

Например, можно создать Type Switch, проверяющий, содержит ли значение i интерфейса тип int или string:

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)
}

8. Как конкатенировать строки?

Проще всего конкатенировать строки с помощью оператора конкатенации (+), который позволяет складывать строки так же, как числовые значения.

package main
import "fmt"
func main() { 
    // Создание и инициализация строк с помощью var 
    var str1 string 
    str1 = "Hello "
    var str2 string 
    str2 = "Reader!"
    // Конкатенация строк с помощью оператора +
    fmt.Println("New string 1: ", str1+str2) 
    // Создание и инициализация строк с помощью сокращенного
    // объявления 
    str3 := "Welcome"
    str4 := "Educative.io"
    // Конкатенация строк с помощью оператора + 
    result := str3 + " to " + str4 
    fmt.Println("New string 2: ", result) 
}

Вопросы среднего уровня

9. Опишите шаги тестирования в Golang

Go поддерживает автоматическое тестирование пакетов с помощью настраиваемых тестовых наборов. 

Для формирования такого набора нужно создать файл, оканчивающийся на _test.go и включающий функцию Testxxx, в которой xxx заменяется именем тестируемой функциональности. К примеру, функция, тестирующая возможности авторизации, будет называться TestLogin.

Затем нужно поместить файл теста в тот же пакет, где находится файл, который вы хотите протестировать. Файл тестирования будет пропускаться при стандартном выполнении программы и выполняться только при введении команды go test.

10. Что такое замыкания функций?

Замыкание функции  —  это значение функции, ссылающееся на переменные вне ее тела. Такая функция может обращаться к этим переменным и присваивать им значения.

Например, 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),
        )
    }
}

11. Как в Go реализуется наследование?

Это несколько каверзный вопрос: как такового наследования в Go нет, поскольку он не поддерживает классы.

Тем не менее поведение наследования можно сымитировать, задействовав композицию для определения начального поведения нового объекта на основе существующего объекта структуры. После создания нового объекта его функциональность можно будет расширить за рамки исходной структуры.

type Animal struct {
    // …
}
func (a *Animal) Eat()   { … }
func (a *Animal) Sleep() { … }
func (a *Animal) Run() { … }
type Dog struct {
    Animal
    // …
}

Структура Animal содержит функции Eat(), Sleep() и Run(). Эти функции вкладываются в дочернюю структуру Dog простым добавлением этой структуры в начало реализации Dog.

12. Расскажите об интерфейсах в Go. Чем они являются и как работают?

Интерфейсы  —  это особый тип в Go, который определяет набор сигнатур методов, но не представляет реализации. Значения типа interface могут содержать любое значение, реализующее эти методы.

Интерфейсы, по сути, действуют как плейсхолдеры для методов, которые будут иметь несколько реализаций в зависимости от того, какой объект их использует.

Например, можно реализовать интерфейс geometry, который будет утверждать, что все фигуры, использующие этот интерфейс, должны реализовывать area() и perim().

type geometry interface {
    area() float64
    perim() float64
}

13. Что такое lvalue и rvalue в Golang?

Lvalue:

  • означает расположение в памяти;
  • представляет идентификатор переменной;
  • мутабельно;
  • может находиться слева или справа от оператора =.

Например, в инструкции x =20, x является lvalue, а 20 rvalue.

Rvalue:

  • представляет хранящееся в памяти значение данных;
  • представляет постоянное значение;
  • всегда находится справа от оператора =.

Например, инструкция 10 = 20 будет невалидна, так как здесь rvalue (10) расположено слева от оператора =.

14. Как в Go реализованы конструкции циклов?

В Go есть только одна конструкция цикла: цикл for. В нем используются три компонента, разделенные точкой с запятой.

  • Инструкция Init, которая выполняется до начала цикла. Как правило, здесь объявляется переменная, видимая только внутри области цикла for.
  • Выражение условия, которое перед каждой итерацией вычисляется в логическое значение, определяя, должен ли цикл продолжаться.
  • Инструкция post, выполняемая в конце каждой итерации.

15. Можно ли вернуть из функции несколько значений?

Да. Функции в Go возвращают несколько значений в инструкции return, разделяя их запятыми.

package main
import "fmt"
func main() {
    sum := 0
    for i := 0; i < 10; i++ {
        sum += i
    }
    fmt.Println(sum)
}

Задачи по программированию на Go

16. Реализуйте стек (LIFO)

Реализуйте структуру стека с функциональностью pop, append и print top.

Решение

Стек можно реализовать с помощью объекта среза.

package main
import "fmt"
func main() {
// Создание стека
var stack []string
// Добавление элементов
stack = append(stack, "world!")
stack = append(stack, "Hello ")
for len(stack) > 0 {
        // Print top
        n := len(stack) - 1
        fmt.Print(stack[n])
        // Pop
        stack = stack[:n]
}
// Output: Hello world!
}

Сначала мы используем встроенную функцию append() для реализации поведения присоединения. Далее с помощью len(stack)-1 мы извлекаем верхушку стека и выводим ее на экран.

Для pop мы устанавливаем в качестве новой длины стека позицию выведенного верхнего значения, len(stack)-1.

17. Выведите все пермутации символов среза или строки

Реализуйте функцию perm(), принимающую срез или строку и выводящую все возможные комбинации его (ее) символов.

Решение

package main
import "fmt"
// Perm вызвает f с каждой пермутацией a.
func Perm(a []rune, f func([]rune)) {
        perm(a, f, 0)
}
// Пермутируем значения в индексе i на len(a)-1.
func perm(a []rune, f func([]rune), i int) {
        if i > len(a) {
                f(a)
                return
        }
        perm(a, f, i+1)
        for j := i + 1; j < len(a); j++ {
                a[i], a[j] = a[j], a[i]
                perm(a, f, i+1)
                a[i], a[j] = a[j], a[i]
        }
}
func main() {
Perm([]rune("abc"), func(a []rune) {
        fmt.Println(string(a))
})
}

Мы используем типы rune для обработки и срезов, и строк. Runes являются кодовыми точками из Unicode, а значит могут парсить строки и срезы одинаково.

18. Поменяйте местами значения переменных без использования промежуточной переменной

Реализуйте swap(), обменивающую значения двух переменных, не используя третью переменную.

Решение

Во многих других языках над этой задачей придется подумать, в Go же реализовать ее просто.

package main
import "fmt"
func main() {
   fmt.Println(swap())
}
func swap() []int {
      a, b := 15, 10
   b, a = a, b
   return []int{a, b}
}

Достаточно просто включить инструкцию b, a = a, b, на чьи данные будет ссылаться переменная, не взаимодействуя ни с одним из ее значений.

19. Реализуйте поведение min и max

Реализуйте функции Min(x, y int) и Max(x, y int), получающие два целых числа и возвращающих меньшее или большее значение соответственно.

Решение

По умолчанию Go поддерживает min и max только для чисел с плавающей запятой, используя для этого math.min и math.max. Вам потребуется создать собственные реализации, чтобы то же самое можно было проделать для целых чисел.

package main
import "fmt"
// Min возвращает меньшее из x или y.
func Min(x, y int) int {
        if x > y {
                return y
        }
        return x
}
// Max возвращает большее из x или y.
func Max(x, y int) int {
        if x < y {
                return y
        }
        return x
}
func main() { 
    fmt.Println(Min(5,10))
    fmt.Println(Max(5,10))
}

20. Переверните порядок элементов в срезе

Реализуйте функцию reverse, получающую срез целых чисел и разворачивающую его без использования временного среза.

Решение

package main
import "fmt"
func reverse(sw []int) {
        for a, b := 0, len(sw)-1; a < b; a, b = a+1, b-1 {
                sw[a], sw[b] = sw[b], sw[a]
        } 
}
func main() { 
    x := []int{3, 2, 1} 
    reverse(x)
    fmt.Println(x)
}

Цикл for меняет местами значения каждого элемента среза. Значения будут следовать слева направо, и в итоге все элементы будут развернуты.

21. Как легче всего проверить срез на пустоту?

Создайте программу, проверяющую срез на пустоту. Найдите самое простое решение.

Решение

Легче всего проверить срез на пустоту с помощью встроенной функции len(), которая возвращает длину среза. Если len(slice) == 0, значит срез пуст.

Например:

package main
import "fmt"
func main() {
  r := [3]int{1, 2, 3}
  if len(r) == 0 {
    fmt.Println("Empty!")
  } else {
    fmt.Println("Not Empty!")
  }
}

22. Отформатируйте строку без ее вывода

Найдите самый простой способ отформатировать строку с переменными, не выводя значение. 

Решение

Легче всего это сделать с помощью fmt.Sprintf(), которая возвращает строку, не выводя ее на экран.

Например:

package main

import "fmt"

func main() {
  s := fmt.Sprintf("Size: %d MB.", 85)
  fmt.Println(s)
}

Вопросы, касающиеся конкурентности

23. Объясните разницу между конкурентностью и параллельностью в Go

Конкурентность  —  это, когда программа может работать с несколькими задачами одновременно, а параллельность  —  это, когда программа может одновременно выполнять несколько задач на нескольких процессорах.

Другими словами, конкурентность  —  это свойство программы, которое позволяет нескольким задачам быть запущенными одновременно, но не обязательно одновременно выполняться. Параллельность же относится к свойствам среды выполнения, когда две или более задач выполняются одновременно.

Это значит, что посредством параллельности можно получить конкурентное поведение, но на этом ее возможности не ограничиваются.

Основными инструментами для реализации конкурентности в Go являются горутины и каналы. Горутины  —  это конкурентные легковесные потоки, а каналы позволяют им взаимодействовать в процессе выполнения.

24. Merge sort 

Реализуйте конкурентное решение merge sort (сортировка слиянием), используя горутины и каналы.

В качестве опорной точки можно взять эту последовательную реализацию:

package main
import "fmt"
func Merge(left, right [] int) [] int{
  merged := make([] int, 0, len(left) + len(right))
  for len(left) > 0 || len(right) > 0{
    if len(left) == 0 {
      return append(merged,right...)
    }else if len(right) == 0 {
      return append(merged,left...)
    }else if left[0] < right[0] {
      merged = append(merged, left[0])
      left = left[1:]
    }else{
      merged = append(merged, right [0])
      right = right[1:]
    }
  }
  return merged
}
func MergeSort(data [] int) [] int {
  if len(data) <= 1 {
    return data
  }
  mid := len(data)/2
  left := MergeSort(data[:mid])
  right := MergeSort(data[mid:])
  return Merge(left,right)
}
func main(){
  data := [] int{9,4,3,6,1,2,10,5,7,8}
  fmt.Printf("%v\n%v\n", data, MergeSort(data))
}

Решение

package main
import "fmt"
func Merge(left, right [] int) [] int{
  merged := make([] int, 0, len(left) + len(right))
  for len(left) > 0 || len(right) > 0{
    if len(left) == 0 {
      return append(merged,right...)
    }else if len(right) == 0 {
      return append(merged,left...)
    }else if left[0] < right[0] {
      merged = append(merged, left[0])
      left = left[1:]
    }else{
      merged = append(merged, right [0])
      right = right[1:]
    }
  }
  return merged
}
func MergeSort(data [] int) [] int {
  if len(data) <= 1 {
    return data
  }
  done := make(chan bool)
  mid := len(data)/2
  var left [] int
  go func(){
    left = MergeSort(data[:mid])
    done <- true
  }()
  right := MergeSort(data[mid:])
  <-done
  return Merge(left,right)
}
func main(){
  data := [] int{9,4,3,6,1,2,10,5,7,8}
  fmt.Printf("%v\n%v\n", data, MergeSort(data))
}

В начале при сортировке слиянием мы рекурсивно разделяем массив на right и left стороны и на строках 30-34 вызываем MergeSort для обеих сторон.

Теперь нужно сделать так, чтобы Merge(left, right) выполнялась после получения возвращаемых значений от обоих рекурсивных вызовов, то есть и left и right должны обновляться до того, как Merge(left, right) сможет быть выполнена. Для этого на строке 26 мы вводим канал типа bool и отправляем в него true сразу после выполнения left = MergeSort(data[:mid] (строка 32).

Операция <-done блокирует код на строке 35 до инструкции Merge(left,right), чтобы она не продолжилась, пока горутина не завершится. После завершения горутины и получения true в канале done код переходит к инструкции Merge(left, right) на строке 36.

25. Сумма квадратов

Реализуйте функцию SumOfSquares, получающую целое число c и возвращающую сумму всех квадратов между 1 и c. Вам потребуется использовать инструкции select, горутины и каналы.

Например, ввод 5 приведет к возвращению 55, потому что $1² + 2² + 3² + 4² + 5² = 55$.

В качестве отправной точки можно взять этот код:

package main
import "fmt"
func SumOfSquares(c, quit chan int) {
// ваш код
}
func main() {
  mychannel := make(chan int)
  quitchannel:= make(chan int)
  sum:= 0
  go func() {
    for i := 0; i < 6; i++ {
      sum += <-mychannel
    }
    fmt.Println(sum)
  }()
  SumOfSquares(mychannel, quitchannel)
}

Решение

package main
import "fmt"
func SumOfSquares(c, quit chan int) {
  y := 1
  for {
    select {
    case c <- (y*y):
      y++
    case <-quit:
      return
    }
  }
}
func main() {
  mychannel := make(chan int)
  quitchannel:= make(chan int)
  sum:= 0
  go func() {
    for i := 1; i <= 5; i++ {
      sum += <-mychannel
    }
    fmt.Println(sum)
    quitchannel <- 0
  }()
  SumOfSquares(mychannel, quitchannel)
}

Рассмотрим функцию SumOfSquares. Сначала на строке 4 мы объявляем переменную y, после чего переходим к циклу For-Select. В инструкциях select прописано два кейса.

  • case c <- (y*y): служит для отправки квадрата y по каналу c, который принимается в горутине, созданной в основной рутине.
  • case <-quit: служит для получения сообщения из основной рутины, которое вернется из функции. 

Дальнейшие шаги обучения

Поздравляем с завершением этих практических вопросов! Go находится в процессе активного развития, и прикладные практические задачи являются ключом к наискорейшему его освоению. Для наиболее эффективной подготовки к собеседованию вам понадобится:

  • разработать подробный план обучения;
  • практиковать решение задач на доске;
  • научиться грамотно озвучивать свой мыслительный процесс;
  • подготовиться к поведенческим собеседованиям.

Успехов в обучении!

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи The Educative Team: How To Crack the Top 25 Golang Interview Questions

Предыдущая статья10 инструментов, которые упростят жизнь веб-разработчика
Следующая статьяReact Query - залог эффективных запросов