JavaRush /Курси /Go SELF /Чому os.Args «ламається» — мотивація для flag

Чому os.Args «ламається» — мотивація для flag

Go SELF
Рівень 48 , Лекція 0
Відкрита

1. CLI-аргументи: що реально потрапляє в Go-програму

Коли люди чують «аргументи командного рядка», вони часто уявляють собі один рядок на кшталт todo add buy milk. Але в Go ви отримуєте вже нарізані шматочки — слайс рядків []string. Це зручно, але важливо розуміти правило: перший елемент — імʼя програми, а далі — те, що користувач написав, уже в «розібраному» вигляді.

Перевірмо це на простому прикладі — напишемо мініпрограму, яка просто друкує вхідні дані.

package main

import (
	"fmt"
	"os"
)

func main() {
	fmt.Printf("os.Args = %#v\n", os.Args)
	fmt.Println("program:", os.Args[0]) // приклад: ./app
}

Якщо запустити так: ./app hello world, то os.Args буде приблизно []string{"./app", "hello", "world"}. Тобто Go вже не бачить «цілу команду» — він бачить список «токенів».

Це підводить до першого важливого висновку: коли ви парсите os.Args, ви насправді проєктуєте мінімову введення, яка живе всередині вашої команди. І ця мінімова або буде охайною, або стане «сюрпризом» для всіх, зокрема й для вас через тиждень.

2. Перший успіх: один аргумент — і здається, що все просто

Перші 10 хвилин зазвичай надихають. Потрібно прийняти один обов’язковий аргумент? Легко: перевірили довжину — прочитали os.Args[1]. Приклад: наша заготовка «менеджера задач» приймає заголовок задачі.

package main

import (
	"fmt"
	"os"
)

func main() {
	if len(os.Args) < 2 {
		fmt.Println("бракує TITLE") // бракує TITLE
		return
	}

	title := os.Args[1]
	fmt.Println("title:", title) // title: Купити молоко
}

І справді: «працює ж!». До того моменту, поки не з’являється наступний пункт вимог: «а давайте ще пріоритет». Потім «а давайте позначати виконане». Потім «а давайте друкувати help». Потім «а давайте, щоб можна було вводити назву, яка починається з -». І от тут os.Args починає натякати, що не ламається він — ламаємося ми, бо намагаємося вручну реалізувати те, що вже давно придумали.

3. Щойно з’являється число — ручний розбір починає роздуватися

Щойно ви додаєте числовий аргумент, у коді з’являються дві речі: перевірки кількості аргументів і обробка помилок парсингу. І це нормально, але зверніть увагу, як швидко росте «обв’язка».

Припустімо, хочемо: todo TITLE PRIORITY, де PRIORITY — число.

package main

import (
	"fmt"
	"os"
	"strconv"
)

func main() {
	if len(os.Args) != 3 {
		fmt.Println("Використання: todo TITLE PRIORITY") // Використання: todo TITLE PRIORITY
		return
	}

	title := os.Args[1]

	priority, err := strconv.Atoi(os.Args[2])
	if err != nil {
		fmt.Println("PRIORITY має бути int:", err) // PRIORITY має бути int: ...
		return
	}

	fmt.Println("завдання:", title, "пріоритет:", priority) // завдання: Купити молоко пріоритет: 3
}

Поки що все терпимо. Але важливо помітити: ми вже починаємо писати «власний протокол» — де що лежить, скільки аргументів, який формат, які повідомлення про помилки. А це лише два параметри. Щойно їх стане більше, ваш main почне виглядати як контрольна з розгалужень і страждань.

4. Коли з’являються опції: ручний парсинг розвалюється

Позиційні аргументи — ті, що йдуть у визначеному порядку, — хороші, коли їх мало й порядок природний. Наприклад: cat FILE, go test ./..., git clone URL. Але щойно ви додаєте необов’язкові налаштування, позиційні аргументи перетворюються на гру «вгадай, що мав на увазі користувач».

Уявімо контракт для нашої програми:

  • обов’язкове: TITLE
  • опціональне: priority (за замовчуванням 1)
  • опціональне: done (за замовчуванням false)

Інтуїтивно користувач хоче писати щось на кшталт:

  • todo -title "Buy milk" -priority 3
  • todo -title "Buy milk" -done
  • todo -title "Buy milk"

Якщо ми намагаємося зробити це вручну на os.Args, то швидко потрапляємо в болото: треба ходити по аргументах, розуміти, де ключ, а де значення, перевіряти, чи після ключа є значення, і так далі. Ось приклад «ручного флаг-парсингу» в стилі «я просто спробував, і мені вже не подобається».

package main

import (
	"fmt"
	"os"
	"strconv"
)

func main() {
	title := ""
	priority := 1
	done := false

	for i := 1; i < len(os.Args); i++ {
		switch os.Args[i] {
		case "-title":
			if i+1 >= len(os.Args) {
				fmt.Println("бракує значення для -title") // бракує значення для -title
				return
			}
			title = os.Args[i+1]
			i++
		case "-priority":
			if i+1 >= len(os.Args) {
				fmt.Println("бракує значення для -priority") // бракує значення для -priority
				return
			}
			p, err := strconv.Atoi(os.Args[i+1])
			if err != nil {
				fmt.Println("некоректний -priority:", err) // некоректний -priority: ...
				return
			}
			priority = p
			i++
		case "-done":
			done = true
		default:
			fmt.Println("невідомий аргумент:", os.Args[i]) // невідомий аргумент: ...
			return
		}
	}

	fmt.Println("назва:", title, "пріоритет:", priority, "виконано:", done)
}

Так, це працює… доки ви не додали:

  • короткі форми (-p 3),
  • формат -priority=3,
  • -- як «далі лише позиційні аргументи»,
  • повторювані прапорці,
  • зрозумілу довідку,
  • нормальні повідомлення про помилки та узгоджений usage.

Ви буквально починаєте писати свій мініаналог flag — тільки гірший, бо робите це ввечері, втомленим мозком, без тестів і без бажання підтримувати ще один велосипед.

5. Типові граблі ручного парсингу

Є кілька місць, де ручний розбір на os.Args особливо неприємний. Їх важливо відчути заздалегідь, щоб потім не дивуватися, чому «ніби все було просто».

Уявіть, що користувач хоче додати задачу з назвою, яка починається з дефіса. Наприклад, задача «-reboot router» (так, дивно, але в житті буває всяке). У ручному парсері ви зазвичай інтерпретуєте все, що починається з -, як прапорець. І тут виникає конфлікт: це назва чи параметр?

Далі починається історія з маркером -- (який використовують багато CLI-застосунків), але якщо ви його не заклали в дизайн, ви або ламаєте UX, або вигадуєте костилі.

Ще одне джерело болю — різні форми запису. Користувач може написати -priority 3, а може -priority=3. Якщо ваш парсер підтримує лише одну форму, вам писатимуть: «а чому не як у нормальних програмах?». Якщо підтримує обидві — код розростається.

І ще один момент, який новачки часто недооцінюють: ви майже завжди хочете валідацію «за змістом». Парсер перетворить рядок на int, але не знає, що пріоритет має бути 1..5. У ручному варіанті ви починаєте розносити правила по коду: частина перевірок під час парсингу, частина після, частина десь «дорогою».

Саме так і з’являється програма, в якій не можна безпечно додати новий параметр: здається, що будь-яка зміна може зламати щось у несподіваній гілці. Не тому, що Go слабкий, а тому, що ручний протокол введення почав жити власним життям.

6. Супровід: help, тести та межі відповідальності

Help і usage легко розсинхронізуються

Наступна стадія болю — підказка користувачеві. Спочатку ви пишете щось на кшталт:

fmt.Println("Використання: todo -title <text> [-priority N] [-done]")

Потім додаєте -format, -limit, -verbose, і… забуваєте оновити usage. Або оновлюєте його в одному місці, але у вас є ще два `return` з помилкою, де usage теж друкується, проте вже в іншому форматі.

У підсумку користувач отримує три різні підказки залежно від того, як саме він помилився. А ви отримуєте відчуття, що «воно якось розповзлося».

Це важливий принцип проєктування CLI: help має генеруватися з одного джерела правди. Якщо help живе окремо від визначення параметрів, він завжди відставатиме. Тому хороші бібліотеки прапорців (і стандартний flag) роблять так, щоб опис прапорця був повʼязаний із його оголошенням, а help будувався автоматично.

Коли парсинг змішався з логікою, тестувати стає боляче

Є дуже практична причина, чому ручний розбір на os.Args швидко починає «ламатися»: він майже завжди опиняється прямо всередині main, поруч із логікою програми.

Спочатку це виглядає логічно: «ну а де ще?». Але потім з’являється бажання протестувати бізнес-логіку. А як ви її протестуєте, якщо вона в main і читає os.Args напряму?

Виходить неприємна зв’язка:

  • щоб перевірити логіку, треба запускати програму як процес,
  • щоб перевірити різні варіанти аргументів, треба збирати або підміняти os.Args,
  • щоб перевірити повідомлення про помилки, треба перехоплювати вивід у консоль.

Так, можна, але новачкові це зазвичай некомфортно, і найчастіше тести не з’являються взагалі. А без тестів ваш ручний парсер швидко перетворюється на мінне поле.

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

Мінірефакторинг на os.Args: видно, що ми пишемо свій flag

Щоб фінально «відчути» мотивацію, давайте зробимо невеликий рефакторинг нашого навчального застосунку. Ми поки не будемо підключати flag — це окрема тема, — але зробимо те, що часто роблять у реальному коді на першому етапі: виділимо конфігурацію та функцію парсингу. Це корисно навіть тоді, коли ви поки що читаєте os.Args вручну, бо мозку стає легше.

Спочатку визначимо конфігурацію. Зверніть увагу: тут ми зберігаємо значення, а не «вказівники на щось». Ми хочемо, щоб решта програми взагалі не знала про os.Args.

package main

type Config struct {
	Title    string
	Priority int
	Done     bool
}

Тепер зробимо парсер. Він іще ручний, але вже повертає (Config, error) — як ми звикли з лекцій про помилки.

package main

import (
	"fmt"
	"os"
)

func parseArgs(args []string) (Config, error) {
	if len(args) < 2 {
		return Config{}, fmt.Errorf("бракує TITLE")
	}
	return Config{Title: args[1], Priority: 1, Done: false}, nil
}

І main стає тоншим:

package main

import (
	"fmt"
	"os"
)

func main() {
	cfg, err := parseArgs(os.Args)
	if err != nil {
		fmt.Println("помилка:", err) // помилка: бракує TITLE
		return
	}
	fmt.Println("прийнято:", cfg.Title) // прийнято: Купити молоко
}

Здавалося б, стало краще. Але тепер уявіть, що ви додаєте -priority і -done, і parseArgs знову розростається. Ви поступово приходите до висновку: «ми будуємо універсальний парсер аргументів». Тобто… так. Саме так. Ви починаєте робити flag — тільки в урізаному, несумісному й не протестованому вигляді.

І ось це і є момент, коли потрібно чесно сказати: ручний розбір os.Args хороший як навчальний етап і як рішення для 1–2 параметрів без варіантів. Але якщо CLI хоч трохи схожий на реальний, час використовувати стандартний механізм.

7. Що має вміти нормальний парсер і чому це flag

Якщо зібрати вимоги до «нормального» CLI-парсера, стає зрозуміло, чому його не варто писати самотужки, особливо на старті.

Нормальне рішення має підтримувати зрозумілий синтаксис, де є прапорці (іменовані параметри) і позиційні аргументи (обов’язкові речі за порядком). Воно має однаково обробляти помилки й друкувати допомогу так, щоб вона відповідала реальним параметрам. Воно має задавати значення за замовчуванням і дозволяти вам валідовувати зміст окремо від парсингу типу. І, бажано, воно має бути стандартним — щоб інші люди не вивчали ваш унікальний «діалект аргументів» як нову мову програмування.

У Go для цього є стандартний пакет flag. Його ключова цінність не в тому, що він «магічний», а в тому, що він розв’язує нудну частину: охайно й передбачувано розбирає аргументи, звільняючи вас для логіки застосунку.

8. Типові помилки під час ручного розбору os.Args

Помилка № 1: читати os.Args[1] без перевірки довжини.
Це найчастіша причина паніки в новачків. У Go слайси не пробачають «авось там є елемент». Якщо аргументів немає, звернення до os.Args[1] дасть runtime panic. Навіть якщо ви впевнені, що «користувач завжди передасть», практика швидко пояснить, що користувач — творча особистість.

Помилка № 2: змішувати парсинг аргументів і виконання логіки в одному місці.
Поки програма маленька, здається, що так швидше. Але щойно з’являється друге правило валідації, третє повідомлення про помилку і четвертий параметр — код перетворюється на кашу. Набагато спокійніше жити, коли є «спочатку розібрали, потім перевірили, потім виконали».

Помилка № 3: вважати позиційні аргументи універсальним рішенням.
Позиційні аргументи хороші, доки їх мало. Коли їх стає багато, користувач плутає порядок, а ви додаєте перевірки «len рівно N», потім «N або N+1», потім «якщо третій аргумент схожий на число…». Це шлях до непередбачуваного інтерфейсу.

Помилка № 4: писати usage вручну в кількох місцях.
Сьогодні ви друкуєте Usage: ... в одному місці, завтра додаєте ще одну гілку помилки й забуваєте вставити туди ж. У підсумку інтерфейс стає неузгодженим. Якщо help — частина UX, то розсинхрон help із реальними параметрами сприймається як баг.

Помилка № 5: «тихо проковтувати» невідомі аргументи.
Іноді новачки роблять парсер, який ігнорує все незрозуміле. Здається, що так «менше помилок». Насправді це робить гірше: користувач помилився в -priorty, а програма мовчки відпрацювала з дефолтом. Виходить не помилка, а пастка.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ