1. Вступ
Це цілком природне бажання: у вас є дві задачі (Task), і ви хочете зрозуміти, «це одна й та сама задача» чи «дві різні». Особливо це помітно в практичних ситуаціях: не додати дублікат, знайти вже наявний запис, перевірити, що після обчислень дані не змінилися, або зробити «наївну» перевірку в тестах. Тести в нас будуть пізніше, але сама думка корисна вже зараз.
У Go порівняння виглядає просто й чесно: a == b. Але мова не обіцяє, що будь-який тип можна порівняти. Ба більше, інколи компілятор узагалі забороняє порівняння, а інколи воно синтаксично допустиме, але може призвести до падіння програми під час виконання. Про падіння ми ще говоритимемо окремо, але слова «runtime error» уже не лякають так сильно, коли ви знаєте, що це взагалі таке.
Під comparable у побутовому сенсі Go зазвичай розуміють тип, значення якого можна порівнювати операторами == і !=. У цій лекції ми розберемося, коли структура стає порівнюваною, а коли — ні, і що робити, якщо == недоступний.
2. Правило: структура порівнювана, якщо порівнювані всі поля
Ось головне правило лекції, яке добре б написати маркером на лобі або хоча б у коментарі в коді: структуру можна порівнювати через ==, лише якщо кожне її поле також можна порівнювати через ==. Якщо хоча б одне поле «непорівнюване», уся структура теж стає непорівнюваною — і компілятор забороняє ==.
Це правило рекурсивне. Якщо поле — теж структура, то вона має бути порівнюваною. Якщо поле — масив [N]T, то масив порівнюваний лише тоді, коли порівнюваний його елемент. Якщо поле — вказівник *T, то вказівник порівнюваний за адресою. Якщо поле — зріз або мапа, то все, приїхали: == для таких полів не визначений, окрім порівняння з nil, але це вже окрема історія.
Ось невелика табличка, щоб швидко орієнтуватися і не намагатися «переконати компілятор силою думки»:
| Тип поля | Можна ==? | Що означає == |
|---|---|---|
|
так | порівняння значень |
|
так, якщо порівнюваний |
поелементне порівняння |
|
так, якщо всі поля порівнювані | порівняння всіх полів |
|
так | порівняння адрес |
slice () |
ні | (окрім == nil) |
|
ні | (окрім == nil) |
|
ні | (окрім == nil) |
| interface (any, error, …) | синтаксично так | але може впасти під час виконання, якщо всередині лежить непорівнюване значення |
Останній рядок про інтерфейси звучить трохи підступно, але він чесний: інтерфейси можна порівнювати. Проте якщо всередині інтерфейсу лежить значення непорівнюваного типу, наприклад зріз, порівняння може завершитися panic під час виконання. У новачків це трапляється рідко, але знати про таку ситуацію корисно: інколи «все компілюється», а програма все одно падає.
3. Task як приклад порівнюваної структури
Щоб не заглиблюватися в абстракції, продовжимо наш навчальний невеликий застосунок зі списком задач. Нехай у задачі є ID, Title і прапорець Done. Усі ці поля порівнювані (int, string, bool), отже Task порівнюваний, і ми маємо право використовувати ==.
package main
import "fmt"
type Task struct {
ID int
Title string
Done bool
}
func main() {
a := Task{ID: 1, Title: "Read spec", Done: false}
b := Task{ID: 1, Title: "Read spec", Done: false}
fmt.Println(a == b) // true
}
Що саме відбувається при a == b? Go порівнює поля в порядку оголошення — логічно, ніби ви написали a.ID == b.ID && a.Title == b.Title && a.Done == b.Done). Це зручно: для простих моделей даних ви отримуєте «рівність за даними» майже безплатно.
Практична користь з’являється одразу: можна шукати «точно таку саму» задачу в списку й не плодити дублікати.
package main
import "fmt"
type Task struct {
ID int
Title string
Done bool
}
func containsTask(tasks []Task, x Task) bool {
for _, t := range tasks {
if t == x {
return true
}
}
return false
}
func main() {
tasks := []Task{{ID: 1, Title: "Read spec"}}
fmt.Println(containsTask(tasks, Task{ID: 1, Title: "Read spec"})) // true
}
Так, у структурі є ID, і зазвичай «унікальність» справді будують саме на ньому. Але для лекції корисно побачити, що == порівнює весь вміст об’єкта.
4. Як структура стає непорівнюваною: поле-зріз
У реальному житті дуже швидко з’являється спокуса: «а давайте додамо теги», «а давайте додамо список підзадач», «а давайте додамо історію змін». І найчастіше це означає зрізи та мапи. А зрізи та мапи — непорівнювані типи.
Покажемо це акуратно, не ламаючи наш застосунок: створімо окремий тип лише для демонстрації.
package main
type TaskWithTags struct {
ID int
Title string
Tags []string
}
func main() {
a := TaskWithTags{ID: 1, Title: "Learn Go", Tags: []string{"go", "study"}}
b := TaskWithTags{ID: 1, Title: "Learn Go", Tags: []string{"go", "study"}}
_ = a
_ = b
// _ = (a == b) // НЕ компілюється: поле Tags (slice) не можна порівняти через ==
}
Чому Go такий строгий? Тому що зріз — це не «контейнер значень», а маленька структура-описувач: вказівник на масив, довжина і ємність. Якби == для зрізів існував, довелося б домовлятися, що саме ми порівнюємо: адресу масиву, довжину чи елементи. Це різні операції з різною ціною, тому Go спеціально не робить «магічного» вибору замість вас.
І це не недолік, а захист від ситуацій, коли ви випадково запускаєте дороге глибоке порівняння, думаючи, що це «швидко». У Go дорогі операції прийнято робити явно.
5. Якщо == не можна: порівняння за змістом
Коли структура непорівнювана, вам усе одно потрібно вміти відповідати на запитання: «однакові вони чи ні?». Просто тепер це не задача оператора ==, а задача вашого коду і вашої предметної області.
Найчастіший сценарій у застосунках такий: у сутності є ідентифікатор, і «рівність» означає «це один і той самий ID». Для задач це майже завжди розумно: дві різні задачі можуть мати однаковий Title, але це не робить їх однією і тією самою задачею.
Напишемо маленьку функцію порівняння за ID. Зверніть увагу: це не метод (про методи буде окремо), а звичайна функція.
package main
import "fmt"
type Task struct {
ID int
Title string
Done bool
}
func sameTaskByID(a, b Task) bool {
return a.ID == b.ID
}
func main() {
a := Task{ID: 1, Title: "Write"}
b := Task{ID: 1, Title: "Write (edited title)"}
fmt.Println(sameTaskByID(a, b)) // true
}
А якщо вам потрібне порівняння «за даними», але всередині є непорівнювані поля, наприклад зрізи або мапи? Тоді доведеться порівнювати поля вручну: числа й рядки — через ==, зрізи — елемент за елементом у циклі, мапи — за ключами та значеннями. Це трохи більше коду, зате поведінка стає очевидною: ви точно знаєте, що саме вважається «рівним».
Не заглиблюючись у універсальні підходи на кшталт deep equal, покажу мінімальну ідею на тегах: «рівні, якщо ID однаковий, а набір тегів збігається поелементно і в тому самому порядку».
package main
import "fmt"
type TaskWithTags struct {
ID int
Tags []string
}
func equalTags(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := 0; i < len(a); i++ {
if a[i] != b[i] {
return false
}
}
return true
}
func sameTask(a, b TaskWithTags) bool {
return a.ID == b.ID && equalTags(a.Tags, b.Tags)
}
func main() {
a := TaskWithTags{ID: 1, Tags: []string{"go", "study"}}
b := TaskWithTags{ID: 1, Tags: []string{"go", "study"}}
fmt.Println(sameTask(a, b)) // true
}
Зверніть увагу на важливе приховане запитання: теги — це впорядкований список чи множина? Якщо це множина, то порівняння за порядком буде неправильним. І тут раптом виявляється, що проблема не в Go і не в ==, а в тому, що модель даних має бути чітко визначена. Програмування часто виглядає саме так: ви прийшли «просто порівняти дві штуки», а пішли з філософським запитанням «що взагалі означає однакові?»
6. Вказівники: адреси проти даних
Навіть якщо сама структура порівнювана, ви можете працювати з вказівниками *Task (ми вже знаємо, що таке &x). І тут починаються класичні граблі: порівняння вказівників порівнює адреси. Це не погано, просто це інший зміст.
Покажемо на прикладі: дві однакові задачі в пам’яті, але створені окремо.
package main
import "fmt"
type Task struct {
ID int
Title string
Done bool
}
func main() {
p1 := &Task{ID: 1, Title: "Same data"}
p2 := &Task{ID: 1, Title: "Same data"}
fmt.Println(p1 == p2) // false: різні адреси
fmt.Println(*p1 == *p2) // true: дані однакові
}
Ця подвійна перевірка (p1 == p2 і *p1 == *p2) добре пояснює реальність: «однакова адреса» і «однакові дані» — це різні поняття.
Іноді вам потрібне саме порівняння адрес. Наприклад, ви зберігаєте вказівники у зрізі й хочете зрозуміти, чи вказують дві змінні на один і той самий об’єкт. Але в моделюванні даних частіше потрібна змістова перевірка: чи це одна й та сама сутність, зазвичай за ID, або чи однаковий вміст за полями.
Порівнюваність і ключі map
У Go є ще один важливий зв’язок: ключі в map мають бути порівнюваними, тому що map усередині використовує порівняння та хешування ключів. На практиці це означає просте правило: «не будь-який тип можна покласти в ключі».
Якщо Task порівнюваний, ми можемо використовувати його як ключ. З практичної точки зору це інколи зручно для «множин» (set) або швидкого дедупу.
package main
import "fmt"
type Task struct {
ID int
Title string
Done bool
}
func main() {
seen := make(map[Task]struct{})
t := Task{ID: 1, Title: "Read spec"}
seen[t] = struct{}{}
_, ok := seen[Task{ID: 1, Title: "Read spec"}]
fmt.Println(ok) // true
}
Якщо ж ви спробуєте зробити ключем структуру зі зрізом або мапою всередині, отримаєте помилку компіляції приблизно на кшталт invalid map key type .... Це логічне продовження того самого правила: якщо не можна ==, значить не можна й у ключі map.
Окремий нюанс про інтерфейси: інтерфейсні значення можна порівнювати, і формально їх можна використовувати як ключі map, але якщо всередину інтерфейсу потрапить непорівнюване значення, операції можуть призвести до panic під час виконання. На старті кар’єри краще дотримуватися принципу: «ключі — це прості типи або структури з простих типів», щоб не влаштовувати собі пригод.
7. Типові помилки під час порівняння структур
Помилка №1: очікувати, що == «спрацює для всього», включно зі зрізами та мапами.
Новачок додає в структуру поле Tags []string або Meta map[string]string, а потім дивується, чому компілятор свариться на a == b. Це не примха: мова не знає, що саме ви хочете порівняти — адреси, довжини чи елементи. Лікується це просто: прийняти правило «структура порівнювана лише тоді, коли порівнювані всі поля» і порівнювати явно за потрібним критерієм.
Помилка №2: порівнювати вказівники й думати, що порівнявся вміст.
p1 == p2 для *Task означає «одна й та сама адреса», а не «однакові поля». Через це легко отримати хибний false, коли дані однакові, але об’єкти різні. Якщо потрібно порівняти дані, порівнюйте *p1 == *p2 (коли структура порівнювана) або порівнюйте потрібні поля явно.
Помилка №3: намагатися зробити «універсальну рівність» без визначення змісту.
Іноді хочуть: «нехай задача вважається рівною, якщо збігається все-все-все». Потім додають поле UpdatedAt, потім Tags, потім History, і раптом «рівність» стає або неможливою, або безглуздою. Зазвичай корисніше домовитися, що «одна й та сама сутність» визначається за ID, а «однакові дані» — це окрема перевірка, яка потрібна рідко й лише для конкретного сценарію.
Помилка №4: забувати про інтерфейсні поля й несподівані падіння під час виконання.
Інтерфейси (any, error) порівнювані на рівні синтаксису, але можуть упасти під час порівняння, якщо всередині лежить непорівнюваний динамічний тип. Новачкам простіше не використовувати інтерфейсні поля в моделях даних без явної потреби, а якщо вже використовуєте — зберігати всередині гарантовано порівнювані типи.
Помилка №5: використовувати структуру як ключ map, не розуміючи, що «рівність ключів» — це рівність усіх полів.
Якщо ви зробили map[Task]struct{}, то ключем стає весь Task, а не лише ID. Це інколи корисно, але частіше несподівано: змінили Done — і ключ уже «інший». Якщо хочете унікальність за ID, то ключем має бути int або string, тобто map[int]Task, а не map[Task]....
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ