Мова Go: реабілітація імперативного програмування

Практично всі сучасні мови програмування включають в тому чи іншому вигляді об'єктно-орієнтовані можливості, тим не менш, автори мови Go постаралися максимально обмежитися імперативної парадигми. Це не повинно викликати здивування, якщо врахувати, що одним з авторів мови є Кен Томпсон (розробник UNIX та мови). Така яскраво–виражена імперативність мови може ввести досвідченого об'єктно-орієнтованого програмування деякий подив і посіяти сумніви щодо можливості вирішення сучасних завдань на такому мовою.

Ця стаття покликана допомогти програмістам, зацікавився в Go, розібратися в імперативних особливості мови. Зокрема, допомогти реалізовувати ключові патерни проектування. Крім цього, будуть наведені деякі цікаві рішення реалізовані в самому Go, його стандартної бібліотеки і інструментарії, які приємно здивують багатьох.

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

type User struct{
String Name
Email string
Int Age
}

Крім структур, аналогічним чином можна оголошувати аліаси:

type User UserAlias
type int Number
type string UserName

Що б створити змінну, що містить екземпляр структури, можна зробити декількома способами:

// Оголосити змінну за значенням
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{
String Name
}

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 всі об'єкти передаються через інтерфейс
користувачі := [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, int age) *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("Відновлені 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 в яку передається покажчик на метод звільняє зайняті раніше ресурси.

Resource type 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(Component c)
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: %v\n", 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("привіт")
}
}


Для запуску тестів використовується утиліта go test. За допомогою неї можна прогнати тести, заміряти покриття, запустити бенчмарки, або запустити тест по паттерну. На прикладі проекту gopatterns створеного для опису і перевірки патернів цієї статті це виглядає так:

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

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

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


Висновок
Отже, незважаючи на те, що Go і побудований на імперативної парадигми, тим не менш, має достатньо коштів для реалізації класичних патернів проектування. В цьому відношенні він нічим не поступається популярним об'єктно-орієнтованим мовам. Разом з тим, такі речі, як вбудований менеджер пакетів, підтримка юніт-тестів на рівні інфраструктури мови, вбудовані засоби рефакторінгу та документування коду помітно виділяють мову серед конкурентів, тому подібні речі зазвичай реалізовуються співтовариством.

Все це, навіть без докладного розглядання goroutine, channels, інтерфейсу з нативними бібліотеками, вже виділяє Go.

Загалом, Go показав, що імперативне і структурне програмування не відходить в історію. Сучасний мову, що відповідає основним тенденціям розробки програмного забезпечення, може бути побудований на основі імперативної парадигми, не гірше, ніж на основі об'єктно-орієнтованої або функціональної парадигм.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.