Пять полезных преимуществ языка Go

Язык программирования Go завоевал значительную популярность среди разработчиков в последнее время. Статьи, связанные с языками программирования и записи в различных блогах, пишутся в огромном количестве каждый день и Go не является исключением. Множественные проекты на Go уже заполнили Github. Go конференции и встречи привлекают все больше и больше людей. Этот язык, безусловно, пришел во время. Он стал языком 2016 года в соответствии с TIOBE и недавно даже пробился в элитный клуб из 10 самых популярных языков в мире.

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

1. Динамический синтаксис и статическая безопасность

Ежедневно я используя Ruby, и мне очень нравится его структура динамической типизации. Это упрощает изучение языка, который становится прост в использовании и позволяет программистам быстро писать код. Но это преимущество отлично работает в основном в небольшой кодовой базе. Когда проект начинает быстро расти и становится все более сложным,  это может привести к проблемам с безопасностью и надежностью, которые решают языки со статической типизацией. Даже если программист тщательно проверяет свой код, это не дает 100% гарантии безошибочного кода, например, динамический объект может появится в другом контексте, в котором не ожидаешь. Возможно ли тогда использовать динамические языки программирования и в тоже время не отказываться от статической безопасности? Давайте посмотрим подобные задачи на примерах кода в Go.

Возникает много споров по вопросу о том, является ли Go объектно-ориентированным языком или нет. Даже у авторов языка нет единого мнения на этот счет. Но есть одна из функций объектно-ориентированного, которая встречается в Go — это интерфейсы. И они почти такие же, как и в Java или C++. Они имеют имена и определяют набор сигнатур функций:

type Animal interface { 
     Speak() string 
}

В Go есть аналог классов называемых структурами:

type Dog struct {
  name string
}

А так выглядит объявление функции (метода) для структуры (аналог метод для классов в ООП):

func (d Dog) Speak() string {
  return "Woof!"
}

Это означает, что мы можем вызвать эту функцию для любого экземпляра объекта типа Dog

Эта часть кода может выглядеть немного странно на первый взгляд. Почему мы  описываем функцию отдельно от структуры объекта? И что за странный синтаксис (d Dog)? Давайте разберемся.

Авторы Go хотели предоставить пользователям большую гибкость, позволяя им добавлять свою логику к любому типу, который им нравится (пока он является частью одного и того же пакета) и даже для типов из внешних библиотек. Поэтому они решили реализовать сохранение функции вне структуры. И поскольку компилятор должен знать, какой тип вы расширяете, вы должны явно указать имя расширяемого типа указав (d Dog) в качестве так называемого приемника.

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

func SaySomething(a Animal) {
  fmt.Println(a.Speak())
}

И, как можно увидеть далее, мы поместим экземпляр Dog в качестве аргумента в функцию SaySomething:

dog := Dog{name: "Charlie"}
SaySomething(dog)

«Великолепно», как вы думаете, что нам необходимо сделать для Dog, чтобы реализовать интерфейс Animal?. Абсолютно ничего, это уже сделано! Go использует концепцию, называемую «автоматическая реализация интерфейса». Конструкция, содержащая все методы, определенные в интерфейсе, автоматически выполняет ее. Не существует ключевого слова. Разве это не круто?

Благодаря этой функции и типу вывода, который позволяет нам опустить тип переменной при определении, мы можем чувствовать, что мы работаем в динамически-типизированном языке. Но здесь мы также получаем безопасность типизированной системы.

Почему это важно? Если ваш проект будет написан на динамичном, очень абстрактном языке, в один прекрасный день вы узнаете, что некоторые его части должны быть переписаны на более низком уровне, c использованием компилируемого языка. Я заметил, однако, что довольно сложно убедить программиста Ruby или Python начать писать на статическом языке и попросить их отказаться от гибкости, которую они имели. Но это может быть проще сделать имея функцию «автоматической реализации интерфейса».

2. Разработка нового лучше наследования

Разрабатываемые приложения с использованием объектно-ориентированного подхода очень часто начинают разработку с одного или минимального количества классов. Но постепенно приложение начинает расти и функционал реализованных ранее классов начинает наследоваться новым функционалом. К сожалению, это рано или поздно приведет к огромному древу тесно связанных классов, где добавление новой логики, поддержание простоты и избежание дублирования кода будет очень сложным.

Поэтому, если мы хотим уменьшить риск заблудиться в «темном лесу» своего кода, нужно избегать наследования и придерживаться реализации отдельных, независимых сущностей. В моем случае эта проблема не возникла, потому-что я писал код на языке, который вообще не поддерживает наследование. Вы догадались — этот язык был Go.

Go изначально не поддерживает механизм наследования. Авторы хотели, чтобы язык был простым и понятным. Они не видели необходимости в наследовании, но они включили в язык особенность, которая полезна, когда вы хотите использовать сопоставление сущностей между собой. Чтобы описать это, я буду использовать другой пример:

Предположим, что мы моделируем транспортное средство, которое может иметь различные типы двигателей и кузова:

composition

Реализуем два интерфейса описывающих этот функционал:

type Engine interface {
  Refill()
}

type Body interface {
  Load()
}

Теперь нам необходимо описать структуру Vehicle которая будет сопоставлена с интерфейсом:

type Vehicle struct {
  Engine
  Body
}

Не заметили ничего странного? Я преднамеренно пропустил имена полей, которые эти интерфейсы определяют. Я использовал функцию, называемую встраиваемой. С этого момента каждый отдельный метод, существующий во встроенном интерфейсе, также будет отображаться непосредственно на самой структуре Vehicle. Это означает, что мы можем вызывать, скажем, функцию refill() для любого экземпляре Vehicle, а Go будет пропускать её через реализацию Engine. Мы свободно получаем правильное сопоставление и нам не нужно добавлять какие-либо явные шаблоны делегирования. Вот как это работает на практике:

 

vehicle := Vehicle{Engine: PetrolEngine{}, Body: TruckBody{}}
vehicle.refill()
vehicle.load()

Если понять разницу между  сопоставлением и наследованием, попробуйте Go и напишите что-то более сложное, чем «hello world». Поскольку он вообще не поддерживает наследование, вам нужно научиться сочинять.

3. Каналы, Go-рутины — мощные инструменты для решения задач параллельного выполнения процессов

В Go есть несколько простых и полезных инструментов, которые помогут вам работать с параллельными процессами в своих приложениях — каналы и go-рутины. Что это такое?

Go-рутины (горутины) — это «легкие потоки (threads)» Go. Они не обрабатываются операционной системой, а контролируются планировщиком Go, который включен в каждый бинарный файл. Этот планировщик достаточно умен и способен равномерно распределять выполнение потоков между ядрами процессора. Go-рутины маленькие и легкие, благодаря этому вы можете легко создать огромное количество таких потоков и получить функциональный параллелизм бесплатно.

Канал — это простая «труба» (pipe), которую вы можете использовать для передачи данных между go-рутинами. Вы можете взять и отправить что-то в один конец канала и прочитать его с другого конца. Этот механизм позволяет go-рутинам взаимодействовать между собой асинхронно.

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

func HeavyComputation(ch chan int32) {
  // алгоритм длительного вычисления значения функции

  ch <- result
}

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

Теперь посмотрим, как мы можем его использовать. Сначала нужно создать новый канал типа int32:

ch := make(chan int32)

Затем необходимо вызвать функцию и передать канал в качестве аргумента (параметра):

go HeavyComputation(ch)

Для того чтобы запустить выполнение функции в go-рутине необходимо использовать ключевое слово go. Вы можете подставить его перед вызовом любой функции. Затем Go создаст новую go-рутину адресное пространство и будет использовать его для запуска функции. Все это происходит в фоновом режиме, поэтому основной процесс программы блокироваться не будет, а продолжит своё выполнение сразу после создания go-рутины.

Только что созданная go-рутина будет жить асинхронно, выполняя свою работу, а затем будет отправит результат в канал после  завершения. Получить результат можно следующим образом:

result := <-ch

Когда функция выполнит вычисление и отправить результат мы его получим из канала в основном процессе. Если бы не вызывали функцию через go-рутину, то выполнение программы было бы заблокировано до момента завершения вычисления функции.

Go-рутины и каналы — простые, но очень мощные механизмы для работы с параллельным выполнением кода.

Программисты других языков, похоже, начинают замечать преимущества  параллельного механизма Go. Например, авторы библиотеки concurrent-ruby, портировали каналы Go непосредственно в свой проект.

4. Доступ к общим данным используя обмен памятью

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

В Ruby, например, это может выглядеть следующим образом:

lock = Mutex.new

a = Thread.new {
  lock.synchronize {
    # доступ к общему ресурсу
  }
}

b = Thread.new {
  lock.synchronize {
    # доступ к общему ресурсу
  }
}

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

Очень хорошо это описано в официальной Go документации, которая дает более подробное понимание этого механизма.

Этот подход не является революционным или новым, но почему-то многим разработчикам блокировка по-прежнему кажется  более удобным и правильным для любой проблемы связанной с параллельным доступом. Конечно, это не означает, что блокировка бесполезна. Её можно использовать для реализации простых вещей, таких как атомный счетчик. Но для абстракций более высокого уровня полезно рассмотреть различные методы, такие как те, которые предлагает Go.

5. В исключениях нет ничего исключительного

Языки программирования, которые обрабатывают ошибки в виде исключений, побуждают понимать их особым образом. Перехват ошибок называют «исключениями», т.к. должно произойти нечто исключительное, необычное, что вызовет непредвиденное поведение. Может быть, мне все равно, что произойдет, может я ничего не хочу делать? Может быть, я хочу просто игнорировать это?

Go отличается этим от других языков, потому что у него изначально нет концепции исключений. Может показаться, что это недостаток, но на самом деле это имеет смысл. На самом деле в исключениях нет ничего исключительного. Обычно это только одно из возможных возвращаемых значений из вызываемой функции. Ошибка ввода-вывода во время связи сокета? Это сеть, поэтому мы должны быть готовы. На устройстве нет свободного места? Случается, никто не имеет неограниченного жесткого диска. Запись базы данных не найдена? Ну, это не похоже на что-то невозможное.

Если ошибки — это просто возвращаемые значения, почему мы должны относиться к ним по особенному? Мы не должны. Вот как они обрабатываются в Go. Попробуем открыть файл:

f, err := os.Open("filename.ext")

Как вы можете видеть, эта (и многие другие) функции Go возвращает два значения — обработчик и ошибка. Вся проверка безопасности выполнения кода так же проста, как сравнение ошибки с нолем. Когда файл успешно открыт, мы получаем обработчик, а ошибка установлена в nil. В противном случае мы можем найти там ошибку.

if err != nil {
  fmt.Println(err)
  return err
}

// дальнейшее выполнение кода

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

multiple-value os.Open() in single-value context

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

err declared and not used

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

Заключение

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

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *