Язык Go: реабилитация императивного программирования

в 8:36, , рубрики: паттерны проектирования, Программирование, Проектирование и рефакторинг

Практически все современные языки программирования включают в том или ином виде объектно-ориентированные возможности, тем не менее, авторы языка Go постарались максимально ограничиться императивной парадигмой. Это не должно вызывать удивление, если учесть что одним из авторов языка является Кен Томпсон (разработчик UNIX и языка С). Такая ярко–выраженная императивность языка может ввести опытного объектно-ориентированного программиста в некоторое недоумение и посеять сомнения насчёт возможности решения современных задач на таком языке.

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

Введение: типы, структуры и переменные

Как и во многих императивных языках программирования (C/Algol/Pascal и т.п.), ключевой сущностью является структура. Структуры определяются в Go следующим образом:

type User struct{
	Name string
	Email string
	Age int
}

Кроме структур, аналогичным образом можно объявлять алиасы:

type UserAlias User
type Number int
type UserName string

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

// Объявить переменную по значению
var user0 User
// Либо вывести переменную из инстанса структуры
user1 := User{}
// Вывести по ссылке
user2 := make(User, 1)
user3 := &User{}
// Можно сделать и пустую типизированную ссылку указывающую на nil
var user4 *User

Названия полей структуры при инициализации можно опускать сохраняя последовательность объявления:

u1 := User{Name: "Jhon", Email: "jhon@example.or", Age: 27}
u2 := User{"Jhon", "jhon@example.or", 27}

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

type Planet struct{
    Name string
}

func GetThirdPlanetByRef() *Planet{
    var planet Planet
    planet.Name = "Earth"
    return &planet
}

func GetThirdPlanetByVal() Planet{
    var planet *Planet
    planet = &Planet{Name: "Earth"}
    return *planet
}

Интерфейсы и анонимные структуры вместо наследования

Привычного наследования в Go нет, однако, если рассматривать наследование как механизм передачи а) принадлежности к определённому типу, б) передачи определенного поведения и в) передачи базовых полей, то к таким механизмам наследования можно отнести анонимные структуры и интерфейсы.

Анонимные структуры позволяют избежать дублирования описания полей в структурах. Так, например, если существует некоторая структура User, и на основе этой структуры требуется сделать несколько более специфичных: покупателя Buyer и кассира Cashier, то поля для новых структур можно заимствовать из User следующим образом:

type Buyer struct {
	User
	Balance float64
	Address string
}

type Cashier struct {
	User
	InsurenceNumber string
}

Несмотря на то, что User не связан «родственными связями» и ничто не скажет, что Buyer есть наследник от User, поля структуры User будут доступны и в Buyer/Cashier.

С другой стороны, теперь необходимо реализовывать методы для User/Buyer/Cashier по-отдельности, что не очень удобно, т.к. приводит к гигантскому дублированию.
Вместо этого методы реализующие одинаковое поведение можно преобразовывать в функции принимающие общий интерфейс в качестве аргумента. Примером может служить метод отправки сообщения на почту SendMail(text string). Т.к. единственное, что требуется от каждой из структур — это Email, то достаточно сделать интерфейс с требованием наличия метода GetEmail.

type UserWithEmail interface {
	GetEmail() string
}

func SendMail(u *UserWithEmail, text string) {
    email := u.GetEmail()
    // отправка на почту email
}

func main() {
    // в users все объекты передаются через интерфейс
	users := [UserWithMail]interface{User{}, Buyer{}, Cashier{}}
	for _, u := range users { 
		b.SendEmail("Hello world!!!")
	}
}

Инкапсуляция

В Go нет модификаторов доступа. Доступность переменной, структуры или функции зависит от идентификатора.
Go экспортирует только те сущности, идентификатор которых удовлетворяет обоим условиям:

  1. Идентификатор начинается с заглавной буквы (Unicode class «Lu»)
  2. Идентификатор объявлен в блоке пакета (т.е. никуда не вложен), либо является именем метода или поля

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

Диспетчеризация типов

По сути в Go отсутствует ad-hoc полиморфизм, нет параметрического полиморфизма (т.е. Java-дженериков и c++-шаблонов) и отсутствует явный полиморфизм подтипов.
Другими словами, нельзя определить две функции с одним именем и разной сигнатурой в одном модуле, а так же нельзя сделать общий метод для разных типов.
Т.е. все следующие конструкции в Go незаконны и приводят к ошибкам компиляции:

func Foo(value int64) {
}

// Компилятор выдаст "Foo redeclared in this block", т.е. ошибка переопределения функции
func Foo(value float64) { 
}

type Base interface{
    Method()
}

// Компилятор выдаст "invalid receiver type Base (Base is an interface type)", т.е.  интерфейс не может иметь методов
func (b *Base) Method() {
}

Тем не менее, в Go есть два механизма, которые позволяют эмулировать полиморфное поведение.
Это, во-первых, динамическая диспетчеризация типов, а во-вторых, утиная типизация.

Так любой объект в Go может быть сведён к типу interface{}, что позволяет передавать в функцию переменные произвольного типа:

package main

func Foo(v interface{}) {
}

func main() {
    Foo(123)
    Foo("abs")
}

Т.к. у interface{} не может быть собственных методов, то для того чтобы вернуть доступ к типу существует специальная конструкция switch type:

func Foo(v interface{}) {
    switch t := v.(type) {
    case int:
         // здесь переменная t имеет тип int
    case string:
        // здесь переменная t имеет тип string
    default:
        // неизвестный тип
    }
}

Управление временем жизни переменной

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

func NewUser(name, email string, age int) *User {
    return &User{name, email, age}
}

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

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

Если есть необходимо освобождать ресурсы, можно сделать метод Release:

func (r *Resource) Release() {
    // release resources
}

Конечно же, этот метод не вызовется сам в случае выхода переменной из области видимости или в случае исключения, как это происходит в C++ (к тому же в Go нет исключений). В таких ситуациях предлагается использовать механизм defer, panic и recover. Например, метод Release может быть отсрочен с помощью директивы defer:

func Foo() {
    r := NewResource()
    
    defer r.Release()
    
    if err := r.DoSomething1(); err != nil {
        return
    }
    
    if err := r.DoSomething2(); err != nil {
        return
    }
    
    if err := r.DoSomething3(); err != nil {
        return
    }
}

Это позволяет освободить ресурсы после вызова функции Foo вне зависимости от варианта развития ситуации.
Поведение defer всегда предсказуемо и описывается тремя правилами:

  1. Аргументы отложенной функции вычисляются в момент формирования defer-конструкции;
  2. Отложенные функции вызываются в порядке «последний вошёл – первый вышел» после возврата сообщения обрамляющей функции;
  3. Отложенные функции могут читать и изменять именованные возвращаемые значения.

Как замена исключений выступают встроенные функции panic и recover:

func Bar() {
    panic("something is wrong")
}

func Foo() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("Recovered in Bar: ", r)
        }
    }()
    
    Bar()
    fmt.Prinln("this message will not be printed on panic inside Bar")
}

Паника побуждает все обрамляющие функции завершаться, так что единственная возможность остановить распространение паники — вызвать функцию recover(). Комбинируя использование defer-выражений и panic/recover-функции, можно добиться той же безопасности, что достигается в объектно-ориентированных языках с помощью try/catch конструкций. В частности, предотвратить утечку ресурсов и неожиданное завершение программы.

Если момент уничтожения экземпляра структуры непредсказуем, то единственный путь в Go произвести освобождение ресурсов — воспользоваться функцией SetFinalizer из стандартного пакета «runtime». Она позволяет отловить момент освобождения экземпляра сборщиком мусора.

Паттерны проектирования

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

Одиночка — Singleton

В Go нет модификатора static, когда требуется наличие статической переменной её выносят в тело пакета. На это решение и строится паттерн Singleton в простейшем случае:

type Singleton struct{
}

// именование с маленькой буквы позволяет защитить от экспорта
var instance *Singleton

func GetSingletonInstance() *Singleton {
    if instance == nil {
    
    instance = &Singleton{}
    }
    
    return instance
}

Абстрактная фабрика. Фабричный метод. Строитель — Abstract factory. Factory method. Builder

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

type AbstractProduct interface{
}

// Абстрактная фабрика
type AbstractFactory interface {
    CreateProduct1() AbstractProduct
    CreateProduct2() AbstractProduct
}

// Фабричный метод
type AbstractCreator interface {
    FactoryMethod() AbstractProduct
}

// Строитель
type AbstractBuilder interface {
    GetResult() AbstractProduct
    BuildPart1()
    BuildPart2()
}

Реализация методов конкретных структур один к одному соответствует реализации в объектно-ориентированном программировании.

Примеры можно посмотреть на github:

Абстрактная фабрика;
Фабричный метод;
Строитель.

Прототип — Prototype

Очень часто паттерн Prototype заменяют просто на поверхностное копирование структуры:

type T struct{
    Text string
}

func main(){
    proto := &T{"Hello World!"}
    copied := &T{}
    // поверхностное копирование
    *copied = *proto
    
    if copied != proto {
        fmt.Println(copied.Text)
    }
}

В общем случае задача решается классическим путём, через создание интерфейса с методом Clone:

type Prototype interface{
    Clone() Prototype
}

Пример реализации можно посмотреть на github: Прототип.

RAII

Применение паттерна RAII осложняется отсутствием деструктора, поэтому чтобы получить более-менее приемлемое поведение требуется воспользоваться функцией runtime.setFinalizer в которую передаётся указатель на метод освобождающий занятые ранее ресурсы.

type Resource struct{
}

func NewResource() *Resource {
    // здесь происходит захват ресурса
    runtime.SetFinalizer(r, Deinitialize)
    return r
}

func Deinitialize(r *Resource) {
    // метод освобождающий ресурсы
}

Пример реализации:

RAII.

Адаптер. Декоратор. Мост. Фасад — Adapter. Bridge. Decorator. Facade

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

type RequiredInterface interface {
    MethodA()
}

type Adaptee struct {
}

func (a *Adaptee) MethodB() {
}

type Adapter struct{
    Impl Adaptee
}

func (a *Adapter) MethodA() {
    a.Impl.MethodB()
}

Компоновщик — Composite

Компоновщик реализуется даже проще, т.к. достаточно только два интерфейса Composite (описывающий структурное поведение) и Component (описывающий пользовательские функции):

type Component interface {
    GetName() string
}

type Composite interface {
    Add(c Component)
    Remove(c Component)
    GetChildren() []Component
}

Пример реализации паттерна: Компоновщик.

Цепочка ответственности — Chain of responsibility

Очень распространённый в Go паттерн, правда реализуется в основном через анонимные функции-хендлеры. Их можно встретить в большом количестве, например, в пакете net/http стандартной библиотеки. В классическом варианте паттерн выглядит так:

type Handler interface{
    Handle(msg Message)
}

type ConcreteHandler struct {
    nextHandler Handler
}

func (h *ConcreteHandler) Handle(msg Message) {
    if msg.type == "special_type" {
        // handle msg
    } else if next := h.nextHandler; next != nil {
        next.Handle(msg)
    }
}

Пример реализации: Цепочка ответственности.

Приятные особенности Go

Как было показано, на языке можно воспроизвести практически все классические паттерны проектирования. Тем не менее, это не является главным преимуществом языка. Очень большое значение имеют так же поддержка многопоточности на основе goroutine, каналы данных между потоками, поддержка анонимных функций и замыкание контекста, лёгкая интеграция с C-библиотеками, а так же мощная стандартная библиотека пакетов. Всё это стоит отдельного тщательного рассмотрения, что конечно выходит за рамки статьи.

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

Встроенный менеджер пакетов с поддержкой git, hg, svn и bazaar

В Go всё делится на пакеты, точно так же как в Java всё делится на классы. Главный пакет, с которого начинается исполнение программы, должен называться main. Каждый пакет представляет собой обычно более-менее независимую часть программы, которая включается в main посредством import. Например, что бы воспользоваться стандартным математическим пакетом достаточно ввести import «math». В качестве пути к пакету может выступать и адрес репозитория. Простенькая программа на OpenGL может выглядеть так:

package main

import (
    "fmt"
    glfw "github.com/go-gl/glfw3"
)

func errorCallback(err glfw.ErrorCode, desc string) {
    fmt.Printf("%v: %vn", err, desc)
}

func main() {
    glfw.SetErrorCallback(errorCallback)

    if !glfw.Init() {
        panic("Can't init glfw!")
    }
    defer glfw.Terminate()

    window, err := glfw.CreateWindow(640, 480, "Testing", nil, nil)
    if err != nil {
        panic(err)
    }

    window.MakeContextCurrent()

    for !window.ShouldClose() {
        //Do OpenGL stuff
        window.SwapBuffers()
        glfw.PollEvents()
    }
}

Для того, чтобы скачать все зависимости, достаточно выполнить go get из директории проекта.

Локальная документация по Go

Всегда есть возможность прочитать документацию из командной строки с помощью команды godoc. Например, чтобы получить описание функции Sin из пакета math достаточно ввести команду godoc math sin:

$ godoc math Sin

func Sin(x float64) float64
    Sin returns the sine of the radian argument x.

    Special cases are:

	Sin(±0) = ±0
	Sin(±Inf) = NaN
	Sin(NaN) = NaN

Так же на локальной машине можно запустить клон сервера golang.com, если интернет по каким-то причинам оказался недоступен:

$ godoc -http=:6060

Подробнее о godoc.

Рефакторинг и форматирование из командной строки

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

gofmt -r 'bytes.Compare(a, b) == 0 -> bytes.Equal(a, b)'

Заменит все выражения вида bytes.Compare(a, b) на bytes.Equal(a, b). Даже в том случае если переменные будут называться иначе.

Так же gofmt можно использовать для упрощения распространённых выражений с помощью флага -s. Этот флаг аналогичен следующим подстановкам:

[]T{T{}, T{}} ->	[]T{{}, {}}
s[a:len(s)] -> s[a:]
for x, _ = range v {...} -> for x = range v {...}

Так же gofmt можно использовать для сохранения code style в проекте. Подробнее о gofmt

Юнит-тестирование и бенчмарки

В Go входит специальный пакет для тестирования testing. Что бы создать тесты для пакета, достаточно сделать одноименный файл с суффиксом "_testing.go". Все тесты и бенчмарки начинаются с Test или Bench:

func TestTimeConsuming(t *testing.T) {
    if testing.Short() {
        t.Skip("skipping test in short mode.")
    }
    ...
}

func BenchmarkHello(b *testing.B) {
    for i := 0; i < b.N; i++ {
        fmt.Sprintf("hello")
    }
}

Для запуска тестов используется утилита go test. С помощью неё можно прогнать тесты, замерить покрытие, запустить бенчмарки, либо запустить тест по паттерну. На примере проекта gopatterns созданного для описания и проверки паттернов этой статьи это выглядит так:

$ go test -v
=== RUN TestAbstractFactory
--- PASS: TestAbstractFactory (0.00 seconds)
=== RUN TestBuilder
--- PASS: TestBuilder (0.00 seconds)
=== RUN TestChain
--- PASS: TestChain (0.00 seconds)
=== RUN TestComposite
--- PASS: TestComposite (0.00 seconds)
=== RUN TestFactoryMethod
--- PASS: TestFactoryMethod (0.00 seconds)
=== RUN TestPrototype
--- PASS: TestPrototype (0.00 seconds)
=== RUN TestRaii
--- PASS: TestRaii (1.00 seconds)
=== RUN TestSingleton
--- PASS: TestSingleton (0.00 seconds)
PASS
ok  	gopatterns	1.007s

$ go test -cover
PASS
coverage: 92.3% of statements

$go test -v -run "Raii"
=== RUN TestRaii
--- PASS: TestRaii (1.00 seconds)
PASS
ok  	gopatterns	1.004s

Заключение

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

Всё это, даже без подробного рассматривания goroutine, channels, интерфейса с нативными библиотеками, уже выделяет Go.

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

Автор: deniskreshikhin

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js