Глибоке занурення у систему типів Go

12 хв. читання

Go має дуже цікаву систему типів. Вона уникає класів й наслідування на користь інтерфейсів та композицій, але, з іншого боку, у неї нема шаблонів чи узагальнень (generics). Спосіб, яким вона обробляє колекції, також досить унікальний.

Система типів Go великим планом

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

  • Не існує типів виключень, оскільки обробка помилок Go заснована на коді, що повертається, та інтерфейсі помилок.
  • Відсутнє перевантаження операторів.
  • Немає перевантаження функцій (те саме ім'я функції з різними параметрами).
  • У функціях немає необов'язкових параметрів або параметрів за замовчуванням.

Всі ці упущення спеціально зроблені для того, щоб зробити Go максимально простим.

Псевдоніми типів

В Go ви можете використовувати псевдоніми для створення різноманітних типів, але не можете присвоїти значення базового типу до псевдоніма без перетворення. Наприклад, присвоєння var b int = a у наступній програмі викликає помилку компіляції, тому що тип Age є псевдонімом int, але сам він не являється int:

package main
 
 
type Age int
 
func main() {
    var a Age = 5
    var b int = a
}
 
 
Вивід:
 
tmp/sandbox547268737/main.go:8: cannot use a (type Age) as
type int in assignment

Ви можете згрупувати оголошення типів або використовувати одне оголошення на рядок:

type IntIntMap map[int]int
StringSlice []string
 
type (
    Size   uint64
    Text  string
    CoolFunc func(a int, b bool)(int, error)
)

Базові типи

Тут присутні всі звичайні типи: bool, string, int та uint з явними розмірами бітів, float (32-бітні та 64-бітні) і complex (64-бітні та 128-бітні).

bool
string
int  int8  int16  int32  int64
uint uint8 uint16 uint32 uint64 uintptr
byte // псевдонім для uint8
rune // псевдонім int32, представляє точку коду Юнікоду
float32 float64
complex64 complex128

Рядки

Рядки в Go закодовані в UTF8, і тому можуть представляти будь-який символ Юнікоду. Пакет strings надає багато операцій з рядками. Ось приклад взяття масиву рядків, приведення їх до правильного регістру та приєднання до речення.

package main
 
import (
    "fmt"
    "strings"
)
 
func main() {
    words := []string{"i", "LikE", "the ColORS:", "RED,", 
                      "bLuE,", "AnD", "GrEEn"}
    properCase := []string{}
     
    for i, w := range words {
        if i == 0 {
            properCase = append(properCase, strings.Title(w))
        } else {
            properCase = append(properCase, strings.ToLower(w))
        }
    }
     
    sentence := strings.Join(properCase, " ") + "."
    fmt.Println(sentence)
}

Вказівники

У Go є вказівники. Нульовий вказівник дорівнює nil. Ви можете отримати вказівник на значення, використовуючи оператор & й повернути, використовуючи оператор *. У вас також можуть бути вказівники на інші вказівники.

package main
 
import (
    "fmt"
)
 
 
type S struct {
    a float64
    b string
}
 
func main() {
    x := 5
    px := &x
    *px = 6
    fmt.Println(x)
    ppx := &px
    **ppx = 7
    fmt.Println(x)
}

Об'єктно-орієнтоване програмування

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

Інтерфейси

Інтерфейси є наріжним каменем системи типів Go. Інтерфейс ー лише колекція сигнатур методів. Кожен тип, який реалізує всі методи, є сумісним з інтерфейсом. Ось короткий приклад. Інтерфейс Shape визначає два методи: GetPerimeter() та GetArea(). Об'єкт Square реалізує інтерфейс.

type Shape interface {
    GetPerimeter() uint
    GetArea() uint
}
 
type Square struct {
   side  uint
}
 
func (s *Square) GetPerimeter() uint {
    return s.side * 4
}
 
func (s *Square) GetArea() uint {
    return s.side * s.side
}

Порожній інтерфейс interface{} сумісний з будь-яким типом, оскільки не має ніяких необхідних методів. Порожній інтерфейс потім може вказувати на будь-який об'єкт (аналогічно до Object у Java або до вказівника void у C/C++) та часто використовується для динамічної типізації. Інтерфейси завжди є вказівниками й завжди вказують на конкретний об'єкт.

Структури (Structs)

Структури ー користувацькі типи Go. Структура містить іменовані поля, які можуть бути базовими типами, типами вказівників або іншими типами структур. Ви також можете анонімно вставляти структури в інші структури як одну з форм наслідування реалізації.

У наступному прикладі структури S1 та S2 вставлені в структуру S3, яка також має своє власне поле int та вказівник на свій власний тип:

package main
 
import (
    "fmt"
)
 
 
type S1 struct {
    f1 int
}
 
type S2 struct {
    f2 int
}
 
type S3 struct {
    S1
    S2
    f3 int
    f4 *S3
}
 
 
func main() {
    s := &S3{S1{5}, S2{6}, 7, nil}
     
    fmt.Println(s)
}
 
Вивід:
 
&{{5} {6} 7 <nil>}

Твердження типу (Type Assertions)

Твердження типу дозволяє вам перетворити інтерфейс у його конкретний тип. Якщо ви вже знаєте базовий тип, то просто можете затвердити його. А якщо ви невпевнені ー можете спробувати декілька тверджень типу, поки не знайдете правильний.

У наступному прикладі представлений список елементів, що містять рядкові та не рядкові значення, представлені у вигляді зрізу порожніх інтерфейсів. Код виконує ітерацію по всім речам, намагаючись перетворити кожен елемент в рядок й зберегти всі рядки в окремому зрізі, який він в результаті виводить.

package main
 
import "fmt"
 
func main() {
    things := []interface{}{"hi", 5, 3.8, "there", nil, "!"}
    strings := []string{}
     
    for _, t := range things {
        s, ok := t.(string)
        if ok {
            strings = append(strings, s)
        }
    }
     
    fmt.Println(strings)
     
}
 
Вивід:
 
[hi there !]

Рефлексія (Reflection)

Пакет Go reflect дозволяє відразу перевіряти тип інтерфейсу без тверджень типу. Ви також можете отримати значення інтерфейсу й за бажанням конвертувати його в інтерфейс (не так корисно).

Ось приклад, аналогічний минулому, але замість виводу рядків, він просто їх підраховує, тому нема потреби перетворювати interface{} у string. Ключовим являється виклик reflect.Type() для отримання типу об'єкта, який має метод Kind(), що дозволяє нам визначати, з рядком ми маємо справу, чи ні.

package main
 
import (
    "fmt"
    "reflect"
)
 
 
func main() {
    things := []interface{}{"hi", 5, 3.8, "there", nil, "!"}
    stringCount := 0
     
    for _, t := range things {
        tt := reflect.TypeOf(t)
        if tt != nil && tt.Kind() == reflect.String {
            stringCount++
        }
    }
     
    fmt.Println("String count:", stringCount)
}

Функції

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

Наступний приклад демонструє кілька функцій ー GetUnaryOp() та GetBinaryOp(), які повертають анонімні функції, обрані випадковим чином. Головна програма вирішує ー їй потрібна унарна чи бінарна операція, заснована на кількості аргументів. Вона зберігає обрану функцію у локальній змінній під назвою «op», а потім викликає її з правильною кількістю аргументів.

package main
 
import (
    "fmt"
    "math/rand"
)
 
type UnaryOp func(a int) int
type BinaryOp func(a, b int) int
 
 
func GetBinaryOp() BinaryOp {
    if rand.Intn(2) == 0 {
        return func(a, b int) int { return a + b }
    } else {
        return func(a, b int) int { return a - b }
    }
}
 
func GetUnaryOp() UnaryOp {
    if rand.Intn(2) == 0 {
        return func(a int) int { return -a }
    } else {
        return func(a int) int { return a * a }
    }
}
 
 
func main() {
    arguments := [][]int{{4,5},{6},{9},{7,18},{33}}
    var result int
    for _, a := range arguments {
        if len(a) == 1 {
            op := GetUnaryOp()
            result = op(a[0])            
        } else {
            op := GetBinaryOp()
            result = op(a[0], a[1])                    
        }
        fmt.Println(result)                
    }
}

Канали

Канали ー незвичайний тип даних. Ви можете думати про них як про черги повідомлень, використовуваних для передачі повідомлень між goroutines. Канали суворо типізовані. Вони синхронізовані та мають виділену підтримку синтаксису для надсилання й отримання повідомлень. Кожен канал може бути тільки для приймання, тільки для відправлення, або двостороннім.

Канали також можуть бути буферизовані. Ви можете перебирати повідомлення у каналі, використовуючи діапазон (range), а goroutines можуть блокуватися у декількох каналах одночасно за допомогою універсальної операції вибору.

Ось типовий приклад, в якому сума квадратів списку цілих чисел паралельно обчислюється двома goroutines, кожна з яких відповідає за половину списку. Головна функція очікує результат з обох goroutines й потім додає їх, щоб одержати повну суму. Зверніть увагу на те, як за допомогою вбудованої функції make() створюється канал c та на те, як з допомогою спеціального оператора <- код читається з каналу та записується у нього.

package main
 
import "fmt"
 
func sum_of_squares(s []int, c chan int) {
    sum := 0
    for _, v := range s {
        sum += v * v
    }
    c <- sum // надсилання у с
}
 
func main() {
    s := []int{11, 32, 81, -9, -14}
 
    c := make(chan int)
    go sum_of_squares(s[:len(s)/2], c)
    go sum_of_squares(s[len(s)/2:], c)
    sum1, sum2 := <-c, <-c // отримання з с
    total := sum1 + sum2
 
    fmt.Println(sum1, sum2, total)

Колекції

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

Масиви

Масиви — колекції фіксованого розміру, що містять елементи одного типу. Ось приклади деяких масивів:

package main
import "fmt"
 
 
func main() {
    a1 := [3]int{1, 2, 3}
    var a2 [3]int
    a2 = a1 
 
    fmt.Println(a1)
    fmt.Println(a2)
     
    a1[1] = 7
 
    fmt.Println(a1)
    fmt.Println(a2)
     
    a3 := [2]interface{}{3, "hello"}
    fmt.Println(a3)
}

Розмір масиву є частиною його типу. Ви можете копіювати масиви однакового розміру і типу. Копіювання відбувається за значенням. Якщо ви хочете зберігати значення іншого типу, ви можете використовувати запасний вихід з масиву порожніх інтерфейсів.

Зрізи (Slices)

Масиви доволі обмежені через їх фіксований розмір. Зрізи є більш цікавими. Ви можете розглядати зрізи як динамічні масиви. Зрізи використовують масив для зберігання своїх елементів. Ви можете перевірити довжину зрізу, додати елементи й інші зрізи, і найцікавіше з цього всього ー ви можете витягти суб-зрізи, подібно до слайсингу в Python:

package main
 
import "fmt"
 
 
 
func main() {
    s1 := []int{1, 2, 3}
    var s2 []int
    s2 = s1 
 
    fmt.Println(s1)
    fmt.Println(s2)
 
    // Modify s1    
    s1[1] = 7
 
    // І s1 й s2 вказують на один і той самий базовий масив
    fmt.Println(s1)
    fmt.Println(s2)
     
    fmt.Println(len(s1))
     
    // Slice s1
    s3 := s1[1:len(s1)]
     
    fmt.Println(s3)
}

Коли ви копіюєте зрізи, ви просто копіюєте посилання на той же базовий масив. Коли ви робите зріз, суб-зріз, як і раніше, вказує на той самий масив. Але коли ви додаєте, то отримуєте зріз, що вказує на новий масив.

Ви можете перебирати масиви чи зрізи, використовуючи звичайний цикл з індексами або діапазони. Ви також можете створювати зрізи заданої місткості, які будуть ініціалізовані нульовим значенням їх типу даних за допомогою функції make():

package main
 
import "fmt"
 
 
 
func main() {
    // Створює зріз на 5 елементів булевого типу, що ініціалізовані як false 
    s1 := make([]bool, 5)
    fmt.Println(s1)
     
    s1[3] = true
    s1[4] = true
 
    fmt.Println("Iterate using standard for loop with index")
    for i := 0; i < len(s1); i++ {
        fmt.Println(i, s1[i])
    }
     
    fmt.Println("Iterate using range")
    for i, x := range(s1) {
        fmt.Println(i, x)
    }
}
 
Вивід:
 
[false false false false false]
Iterate using standard for loop with index
0 false
1 false
2 false
3 true
4 true
Iterate using range
0 false
1 false
2 false
3 true
4 true

Мапи (Maps)

Мапи ー колекції пар ключ-значення. Ви можете присвоїти їм літерали мап або інші мапи. Також ви можете створити порожні мапи, використовуючи вбудовану функцію make. Доступ до елементів можна отримати за допомогою квадратних дужок. Мапи підтримують ітерування з використанням range, а протестувати, чи існує ключ, можна намагаючись отримати доступ до нього й перевіряючи друге необов'язкове булеве значення.

package main
 
import (
    "fmt"
)
 
func main() {
    // Створення мапи, використовуючи літерал мапи
    m := map[int]string{1: "one", 2: "two", 3:"three"}
     
    // Присвоєння елементу за ключем
    m[5] = "five"
    // Доступ до елементу за ключем
    fmt.Println(m[2])
     
    v, ok := m[4]
    if ok {
        fmt.Println(v)
    } else {
        fmt.Println("Missing key: 4")
    }
     
     
    for k, v := range m {
        fmt.Println(k, ":", v)
    }
}
 
Вивід:
 
two
Missing key: 4
5 : five
1 : one
2 : two
3 : three

Зверніть увагу, що ітерація виконується не в порядку створення або вставки.

Нульові значення

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

Для будь-якого типу T, *new(T) поверне одне нульове значення Т.

Для булевих типів нульовим значенням є false. Для чисельних типів нульове значення дорівнює ... нулю. Для зрізів, мап та вказівників ー порожній покажчик (nil). Для структур ー структура, де всі поля ініціалізуються нульовим значенням.

package main
 
import (
    "fmt"
)
 
 
type S struct {
    a float64
    b string
}
 
func main() {
    fmt.Println(*new(bool))
    fmt.Println(*new(int))
    fmt.Println(*new([]string))
    fmt.Println(*new(map[int]string))
    x := *new([]string)
    if x == nil {
        fmt.Println("Uninitialized slices are nil")
    }
 
    y := *new(map[int]string)
    if y == nil {
        fmt.Println("Uninitialized maps are nil too")
    }
    fmt.Println(*new(S))
}

Як щодо шаблонів чи generics?

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

  • Якщо у вас є тільки декілька екземплярів, подумайте про створення лише конкретних об'єктів.
  • Використати порожній інтерфейс (у якийсь момент вам доведеться додати твердження у ваш конкретний тип)
  • Використати генерування коду.
Помітили помилку? Повідомте автору, для цього достатньо виділити текст з помилкою та натиснути Ctrl+Enter
Codeguida 5.8K
Приєднався: 8 місяців тому
Коментарі (0)

    Ще немає коментарів

Щоб залишити коментар необхідно авторизуватися.

Вхід / Реєстрація