1. Коли потрібен type switch
Майже кожен новачок проходить одну й ту саму стадію: «Я зараз покладу значення в any, а потім… ну якось розберуся». Це приблизно як скласти всі дроти в один пакет і сподіватися, що інтернет сам здогадається, де живлення, а де HDMI. Type switch — це спосіб розібратися без хаосу, коли значення приходить як інтерфейс (any, error або ваш інтерфейс), а далі потрібно обрати логіку за конкретним типом.
Типові ситуації, де type switch справді доречний: ви приймаєте значення типу any, бо воно змішане; отримуєте error і хочете зрозуміти, чи не є це конкретним типом помилки; будуєте диспетчер команд або подій, де різні команди представлені різними структурами, але зберігаються за інтерфейсом. До речі, Go офіційно розглядає type assertion і type switch як способи побачити конкретний тип помилки, тому що error — це інтерфейс.
2. Звичайний switch і type switch
На вигляд вони схожі, але насправді це два різні інструменти. Звичайний switch обирає гілку за значенням виразу (наприклад, cmd == "add" або day == 5). Type switch обирає гілку за типом значення, яке лежить усередині інтерфейсу (наприклад, усередині any лежить int або string).
Коли ви пишете звичайний switch, компілятор заздалегідь знає тип виразу. Коли ви пишете type switch, компілятор знає лише одне: це інтерфейс. А далі ви просите: перевір, який конкретний тип справді лежить усередині, і дай мені змінну вже з цим типом.
Для зручності зафіксуємо це в таблиці — її корисно тримати в голові, щоб не намагатися «прикрутити» type switch туди, де потрібен звичайний:
| Що порівнюємо | Інструмент | Що обирається | Приклад думки |
|---|---|---|---|
| Значення | |
гілка за значенням | «якщо команда дорівнює "add"…» |
| Тип усередині інтерфейсу | |
гілка за типом | «якщо всередині лежить …» |
Синтаксис: switch v := x.(type) { ... }
Запис x.(type) не можна використовувати як самостійний вираз. Він існує лише всередині конструкції type switch. Тобто ви не можете написати t := x.(type) — компілятор просто не прийме це.
Канонічний вигляд:
switch v := x.(type) {
case int:
// v має тип int
case string:
// v має тип string
default:
// v усе ще доступний як x (інтерфейс), а v — не існує
}
Тут одночасно відбуваються дві речі.
По‑перше, x має мати інтерфейсний тип (any, error або ваш інтерфейс).
По‑друге, змінна v всередині кожної гілки отримує конкретний тип цієї гілки, і ви можете працювати з нею без додаткових перевірок типу.
Мініприклад: функція describe
Уявімо, що в нас є налагоджувальна функція, яка друкує опис значення:
package main
import "fmt"
func describe(x any) {
switch v := x.(type) {
case int:
fmt.Println("int:", v) // int: 7
case string:
fmt.Println("string:", v) // string: go
default:
fmt.Printf("unknown: %T\n", x) // unknown: bool
}
}
Зверніть увагу: всередині case int: змінна v — це вже int, а не any. Тобто ви можете виконувати арифметику, порівняння та будь‑які операції, дозволені для int.
3. Приклад: диспетчер команд
Далі попрацюємо з невеликим консольним застосунком «TaskBox» (умовна назва), де ми зберігатимемо задачі в пам’яті. Сьогодні ми не будемо будувати складну архітектуру, а зробимо одну конкретну річ: навчимося представляти команди як різні структури й обробляти їх однією функцією через type switch.
Ідея така: парсер команд повертає any, тому що команда може мати різну форму. Наприклад, AddCmd, DoneCmd, ListCmd. А далі диспетчер розгалужує їх за типом.
Спочатку мінімально опишімо типи команд:
package main
type AddCmd struct{ Title string }
type DoneCmd struct{ ID int }
type ListCmd struct{}
Тепер напишімо «виконавець команд». У реальному проєкті ви, швидше за все, зробили б інтерфейс із методом Run(), але зараз нам важливо саме показати type switch як механізм диспетчеризації за конкретними типами:
package main
import "fmt"
func runCommand(cmd any) error {
switch c := cmd.(type) {
case AddCmd:
fmt.Println("ADD:", c.Title) // ADD: Buy milk
case DoneCmd:
fmt.Println("DONE:", c.ID) // DONE: 3
case ListCmd:
fmt.Println("LIST") // LIST
default:
return fmt.Errorf("unknown command type %T", cmd)
}
return nil
}
Головна перевага тут у тому, що в кожній гілці c має конкретний тип. У гілці AddCmd у c є поле Title, а в гілці DoneCmd — поле ID. Жодних окремих перевірок типу через cmd.(AddCmd), жодних ok, жодних ланцюжків if.
Щоб було зовсім наочно, ось як це може виглядати в main:
package main
import "fmt"
func main() {
_ = runCommand(AddCmd{Title: "Buy milk"}) // ADD: Buy milk
_ = runCommand(DoneCmd{ID: 3}) // DONE: 3
_ = runCommand(ListCmd{}) // LIST
fmt.Println("ok") // ok
}
Так, це поки що лише «іграшка». Але саме з таких прикладів згодом виростають реальні диспетчери: події, повідомлення, різні варіанти відповіді від API тощо.
4. Корисні нюанси
Кілька типів в одному case
Коли типів стає більше, ви майже неминуче захочете обробляти кілька типів однаково.
Наприклад, числові типи:
package main
import "fmt"
func printNumber(x any) {
switch v := x.(type) {
case int, int64:
fmt.Printf("number: %v\n", v) // number: 10
default:
fmt.Printf("not a number: %T\n", x)
}
}
Нюанс тут такий: коли в case перелічено кілька типів, гілка працює однаково для всіх цих варіантів. Якщо ж вам потрібні специфічні операції, такі варіанти краще розділити на окремі case.
Гілка «все, що реалізує інтерфейс»
У type switch можна робити гілку не лише за конкретним типом, а й за інтерфейсом. Це схоже на перевірку поведінки: нам не важливо, який саме тип, головне — щоб були потрібні методи.
Припустімо, ми хочемо гарно друкувати все, що реалізує fmt.Stringer. Тоді можна написати case fmt.Stringer:
package main
import (
"fmt"
)
type Task struct{ Title string }
func (t Task) String() string { return "Task: " + t.Title }
func show(x any) {
switch v := x.(type) {
case fmt.Stringer:
fmt.Println(v.String()) // Task: Read Go book
default:
fmt.Printf("%v\n", x)
}
}
Перевага в тому, що case fmt.Stringer спрацює для будь-яких типів, які реалізують цей інтерфейс, а не лише для Task.
case nil і typed nil
З nil в інтерфейсах пов’язана одна з найнеприємніших пасток для новачків, тому що інколи здається: «я все зробив правильно, а Go мене тролить». Насправді Go не тролить — він просто буквально дотримується моделі «тип + значення».
У інтерфейсу є два стани.
Перший: інтерфейс справді порожній (nil), тобто в ньому немає ні типу, ні значення.
Другий: в інтерфейсі є тип, але значення цього типу — nil (наприклад, *Task зі значенням nil). У другому випадку інтерфейс уже не nil, тому що тип уже є.
Подивімося на короткому прикладі:
package main
import "fmt"
type Task struct{ Title string }
func main() {
var x any
fmt.Println(x == nil) // true
var t *Task = nil
x = t
fmt.Println(x == nil) // false
fmt.Printf("type=%T\n", x) // type=*main.Task
}
Тепер найважливіше про type switch: case nil спрацьовує лише в першому випадку — коли інтерфейс справді nil. Якщо всередині typed nil, то case nil не спрацює, тому що тип усередині вже є.
Ось як це виглядає в type switch:
package main
import "fmt"
type Task struct{ Title string }
func main() {
var t *Task = nil
var x any = t
switch v := x.(type) {
case nil:
fmt.Println("nil interface")
case *Task:
fmt.Println("typed nil pointer:", v == nil) // typed nil pointer: true
}
}
Зверніть увагу на останній рядок: у гілці case *Task: змінна v має тип *Task, і при цьому вона справді nil. Тобто typed nil — це не «магічний несправжній nil», а справжній nil-вказівник, просто інтерфейс навколо нього вже не порожній.
Порядок case: спочатку конкретне, потім загальне
Коли в type switch є і конкретні типи, і інтерфейси, порядок гілок стає важливим. Причина проста: якщо ви поставите занадто широкий case раніше, він перехопить усе, і до вузької гілки виконання не дійде.
Наприклад, Task реалізує fmt.Stringer. Якщо ви поставите case fmt.Stringer: вище case Task:, то гілка Task може взагалі ніколи не виконатися для значення Task, тому що воно вже потрапило в fmt.Stringer.
Покажімо це коротко:
package main
import (
"fmt"
)
type Task struct{ Title string }
func (t Task) String() string { return "Task(" + t.Title + ")" }
func main() {
var x any = Task{Title: "Learn type switch"}
switch v := x.(type) {
case fmt.Stringer:
fmt.Println("stringer:", v.String()) // stringer: Task(Learn type switch)
case Task:
fmt.Println("task:", v.Title)
}
}
Якщо ви справді хочете окрему обробку для Task, то просто змініть порядок: спочатку case Task:, потім case fmt.Stringer:. Це схоже на звичайні перевірки умов: спочатку більш специфічне, потім більш загальне.
5. Типові помилки під час роботи з type switch
Помилка № 1: спроба використати .(type) поза switch.
Дуже часта ситуація: студент намагається написати щось на кшталт t := x.(type) або вставити x.(type) у fmt.Printf. Так не можна за синтаксисом Go: .(type) існує лише як частина type switch. Якщо потрібно дістати конкретний тип для одного варіанта — використовуйте type assertion v, ok := x.(T). Якщо варіантів багато — краще type switch.
Помилка № 2: type switch за значенням, яке не є інтерфейсом.
Type switch працює лише тоді, коли вираз має інтерфейсний тип. Тобто switch x.(type) коректний, якщо x — any, error або інший інтерфейс. Якщо x — int, string або Task, то це просто не має сенсу: конкретний тип уже відомий, і компілятор не дозволить таку конструкцію.
Помилка № 3: очікування, що case nil зловить typed nil.
Це логічна пастка: «ну всередині ж nil — значить case nil». Але case nil перевіряє не значення всередині, а стан самого інтерфейсу. Якщо інтерфейс зберігає тип *Task, то він уже не nil, навіть якщо значення — nil. У таких місцях корисно прямо виводити %T і пам’ятати модель «тип + значення».
Помилка № 4: відсутність default і тихе ігнорування невідомих типів.
Якщо ви робите type switch за any, а default не визначили, то неочікуваний тип просто буде проігноровано. У навчальних прикладах це може «спрацювати», а в реальному коді перетворюється на загадкові баги: функція нічого не зробила — і незрозуміло чому. Зазвичай default — це або явна помилка, або зрозумілий резервний варіант (наприклад, fmt.Printf("unknown %T", x)).
Помилка № 5: неправильний порядок гілок, коли є і конкретні типи, і інтерфейси.
Якщо поставити case fmt.Stringer: вище case Task:, то Task почне оброблятися «як stringer», і ваша спеціалізована логіка не виконається. У підсумку ви дивитеся на код і думаєте: «чому гілка Task не спрацьовує?». Відповідь зазвичай банальна: вона недосяжна через більш загальний case, який стоїть раніше.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ