JavaRush /Курси /Go SELF /Розбір позиційних аргументів і помилок парсингу

Розбір позиційних аргументів і помилок парсингу

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

1. Позиційні аргументи: що це і навіщо вони потрібні

Поки прапорці на кшталт -title="..." виглядають акуратно й офіційно, позиційні аргументи часто сприймаються як щось «на здачу»: мовляв, хай уже будуть, якщо ліньки вигадувати прапорці. Насправді позиційні аргументи — це цілком нормальна частина інтерфейсу командного рядка: git commit <message>, mkdir <dir>, go test <pkgs>. Важливо лише, щоб вони розбиралися строго й передбачувано, інакше користувачеві доведеться вгадувати наміри вашої програми. А це, на жаль, платна опція.

Позиційні аргументи — це ті слова в командному рядку, які лишаються після прапорців. Наприклад, у такому запуску:

app done -force 12

для команди done прапорець -force — це прапорець, а 12 — позиційний аргумент, найімовірніше ID задачі.

У нашому навчальному застосунку — простому todo CLI — позиційні аргументи особливо зручні для речей на кшталт done <id> або rm <id>. Людині простіше ввести одне число, ніж пам’ятати, що треба написати -id=12. Але за зручність доводиться платити дисципліною: треба строго перевіряти кількість аргументів і акуратно парсити типи.

2. Розбір аргументів: Parse → Args/NArg/Arg

Спочатку Parse, потім Args/NArg/Arg

Якщо ви запам’ятаєте одну фразу з цієї лекції, нехай це буде вона: позиційні аргументи команди читаємо лише після fs.Parse(argv) і лише через fs.Args()/fs.NArg()/fs.Arg(i). Не через початковий argv, не через «ну я ж бачу, що там другий елемент», не через магію чи передчуття. Бо пакет flag уміє «з’їдати» прапорці, зміщувати акценти й лишати вам рівно те, що справді є аргументами.

Причина проста: доки Parse не відпрацює, ви не знаєте, що з argv було прапорцями, а що — звичайним текстом. flag розбирає вхід за своїми правилами, і ваше завдання — прийняти результат. Як у ресторані: спочатку офіціант (Parse) приймає замовлення, а потім ви (Args) отримуєте страви. Якщо ж почати їсти «за списком у меню», можна випадково з’їсти сільничку.

Мінімальний каркас обробника команди виглядає так:

package main

import (
	"flag"
)

func runDone(argv []string) error {
	fs := flag.NewFlagSet("done", flag.ContinueOnError)

	if err := fs.Parse(argv); err != nil {
		return err
	}

	// Лише тепер безпечно читати fs.Args()/fs.NArg()/fs.Arg(i)
	return nil
}

Чому не можна брати позиційні аргументи напряму з argv

Іноді новачок пише так: «У мене команда done, отже argv[0] — це ID». І справді, у простому випадку app done 12 так і буде. Але щойно з’являються прапорці, усе стає значно підступнішим. Наприклад:

app done -force 12

Якщо ви візьмете argv[0], ви отримаєте рядок -force, а не 12. І потім будете дуже щиро парсити -force через strconv.Atoi й отримувати помилку, яка виглядає як «чому число не число». У цей момент користувач уже починає сумніватися, що вміє рахувати, хоча проблема не в ньому.

fs.Parse(argv) якраз і потрібен, щоб відокремити прапорці від позиційних аргументів, а потім віддати вам «чистий» список через fs.Args(). Тож argv можна сприймати як сирі дані, а fs.Args() — як очищений список, готовий до роботи.

Покажу це на маленькому прикладі «сире проти очищеного»:

package main

import (
	"flag"
	"fmt"
)

func demo(argv []string) {
	fs := flag.NewFlagSet("demo", flag.ContinueOnError)
	_ = fs.Bool("force", false, "режим примусу")

	_ = fs.Parse(argv)

	fmt.Println("сирий argv:", argv)       // сирий argv: [-force 12]
	fmt.Println("позиційні аргументи:", fs.Args())  // позиційні аргументи: [12]
}

3. Інструменти Args, NArg, Arg(i)

Коли ви вже викликали Parse, у FlagSet є три головні способи працювати з позиційними аргументами. Вони схожі, але корисні в різних ситуаціях. Думайте про це як про три способи подивитися на чергу: можна дізнатися її довжину, взяти елемент за індексом або отримати весь список.

Зведена таблиця:

Метод Що дає Коли зручно
fs.NArg()
кількість позиційних аргументів коли команда вимагає «рівно N»
fs.Arg(i)
позиційний аргумент за індексом коли потрібен конкретний аргумент, наприклад ID
fs.Args()
слайс усіх позиційних аргументів коли аргументів багато або ви хочете перебрати їх у циклі

У нашому todo CLI типові контракти такі: у done потрібен рівно один аргумент, у list зазвичай нуль (ми задаємо фільтри прапорцями), у add іноді зручно вважати, що все, що лишилося, — це текст задачі, тобто Args().

Приклад суворої перевірки «рівно один аргумент»:

package main

import (
	"errors"
	"flag"
	"fmt"
)

var ErrUsage = errors.New("usage")

func runDone(argv []string) error {
	fs := flag.NewFlagSet("done", flag.ContinueOnError)

	if err := fs.Parse(argv); err != nil {
		return fmt.Errorf("%w: %v", ErrUsage, err)
	}
	if fs.NArg() != 1 {
		return fmt.Errorf("%w: usage: app done <id>", ErrUsage)
	}
	return nil
}

Тут ErrUsage — це маркер «користувач запустив програму неправильно». Ми обгортаємо його через %w, щоб пізніше можна було розпізнати помилку через errors.Is. Ідея обгортання через %w якраз у тому, що ми зберігаємо початкову причину всередині нової помилки.

4. Контракт позиційних аргументів

Команда в CLI — це маленька угода між людиною та програмою. І позиційні аргументи в цій угоді відповідають за дві речі: за кількість і за зміст. Коли ви проєктуєте команду, майже завжди можна сформулювати правило так: «команда done вимагає один аргумент — id задачі, тобто ціле число». Це правило треба в коді реалізувати буквально, без телепатії.

Хороша новина: реалізувати це в Go дуже просто. Погана — слово «простий» часто провокує думку «і так зійде», а потім команда починає мовчки ігнорувати зайві аргументи. Мовчання в CLI — майже завжди зло: користувач думає, що все застосувалося, а ваша програма просто викинула половину команди в смітник.

Давайте зробимо невелику допоміжну функцію для перевірки кількості аргументів. Вона повертатиме помилку з маркером ErrUsage, щоб класифікація була стабільною.

package main

import (
	"errors"
	"flag"
	"fmt"
)

var ErrUsage = errors.New("usage")

func requireNArgs(fs *flag.FlagSet, n int, usage string) error {
	if fs.NArg() != n {
		return fmt.Errorf("%w: usage: %s", ErrUsage, usage)
	}
	return nil
}

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

5. Парсинг типів і змістовні помилки

Після того як ви перевірили кількість позиційних аргументів, починається другий шар контракту: типи. Якщо ми очікуємо число, треба перетворити рядок на int і коректно обробити помилку. У Go це робиться через strconv.Atoi. Сама по собі помилка Atoi корисна, але користувачеві часто важливіший контекст: який саме аргумент зламався і яке значення прийшло.

Для команди done це виглядає так:

package main

import (
	"fmt"
	"strconv"
)

func parseID(s string) (int, error) {
	id, err := strconv.Atoi(s)
	if err != nil {
		return 0, fmt.Errorf("ID має бути цілим числом, отримано %q", s)
	}
	return id, nil
}

Тут %q друкує рядок у лапках, що корисно, коли користувач випадково передав пробіли або дивні символи. Наприклад, у виводі got " 12" одразу видно, що десь є пробіл.

Тепер поєднаємо це з ErrUsage — бо «ID не число» майже завжди є помилкою використання:

package main

import (
	"errors"
	"fmt"
)

var ErrUsage = errors.New("usage")

func parseIDUsage(s string) (int, error) {
	id, err := parseID(s)
	if err != nil {
		return 0, fmt.Errorf("%w: %v", ErrUsage, err)
	}
	return id, nil
}

Ми знову використовуємо обгортання через %w, щоб ErrUsage не загубився в ланцюжку причин. Це відповідає загальному підходу: обгортаємо помилку, але не втрачаємо початкову причину.

6. Помилки парсингу прапорців і класифікація помилок

Позиційні аргументи — лише половина історії. Друга половина — це помилки, які може повернути fs.Parse(argv). І це важлива частина UX: коли людина помилилася в прапорцях, це теж помилка використання. Наприклад, вона написала невідомий прапорець, забула значення, переплутала тип. Такі помилки не повинні маскуватися під «внутрішній збій» програми.

Тому в обробнику команди є стандартний прийом: якщо Parse повернув помилку, ми загортаємо її в ErrUsage і повертаємо вище. Ми не друкуємо її тут, бо друкуватиме верхній рівень, але класифікуємо. І знову — обгортання.

package main

import (
	"errors"
	"flag"
	"fmt"
)

var ErrUsage = errors.New("usage")

func parseFlags(fs *flag.FlagSet, argv []string) error {
	if err := fs.Parse(argv); err != nil {
		return fmt.Errorf("%w: %v", ErrUsage, err)
	}
	return nil
}

Чому тут не %w для err? Бо наша мета — класифікація за ErrUsage, а не обов’язкове збереження всієї низькорівневої структури помилки flag. У деяких проєктах роблять і %w (наприклад, fmt.Errorf("%w: %w", ErrUsage, err)), але тоді помилка матиме «подвійну природу», і треба обережніше думати про те, що errors.Is буде знаходити.

7. Наскрізні приклади: команди done і add

Команда done: прапорці + позиційний ID

Зараз зберемо все в один зв’язний шматок коду, який справді схожий на команду. Уявімо, що в нас є команда done, яка приймає прапорець -force (демо-прапорець — просто щоб було видно, як Parse відокремлює прапорці) і один позиційний аргумент <id>.

package main

import (
	"errors"
	"flag"
	"fmt"
	"os"
	"strconv"
)

var ErrUsage = errors.New("usage")

func runDone(argv []string) error {
	fs := flag.NewFlagSet("done", flag.ContinueOnError)
	fs.SetOutput(os.Stderr)

	force := fs.Bool("force", false, "позначити як виконане примусово (демо)")

	if err := parseFlags(fs, argv); err != nil {
		return err
	}
	if err := requireNArgs(fs, 1, "app done [flags] <id>"); err != nil {
		return err
	}

	id, err := parseIDUsage(fs.Arg(0))
	if err != nil {
		return err
	}

	fmt.Printf("виконано: id=%d force=%v\n", id, *force) // виконано: ID=12 force=true
	return nil
}

func parseID(s string) (int, error) {
	id, err := strconv.Atoi(s)
	if err != nil {
		return 0, fmt.Errorf("ID має бути цілим числом, отримано %q", s)
	}
	return id, nil
}

Тут важливо, що fs.Arg(0) ми викликаємо лише після requireNArgs. Це як пристібатися перед тим, як рушити: можна й без цього, але потім буде дуже прикро й дуже гучно.

Команда add: коли аргументів «багато» і потрібен Args()

Команда add зазвичай додає задачу з текстом. Часта UX-модель: усе, що лишилося після прапорців, — це і є текст задачі. Тобто підтримуємо такі варіанти:

app add "Купити молоко"
app add -priority=2 Купити молоко сьогодні

Тут зручніше взяти fs.Args(), склеїти слова назад і отримати заголовок. Це хороший приклад того, чому Args() інколи зручніше за Arg(0).

package main

import (
	"errors"
	"flag"
	"fmt"
	"strings"
)

var ErrUsage = errors.New("usage")

func runAdd(argv []string) error {
	fs := flag.NewFlagSet("add", flag.ContinueOnError)
	priority := fs.Int("priority", 1, "пріоритет завдання (демо)")

	if err := parseFlags(fs, argv); err != nil {
		return err
	}
	if fs.NArg() == 0 {
		return fmt.Errorf("%w: usage: app add [flags] <title...>", ErrUsage)
	}

	title := strings.Join(fs.Args(), " ")
	fmt.Printf("додано: %q (priority=%d)\n", title, *priority) // додано: "Купити молоко сьогодні" (priority=2)
	return nil
}

Цей підхід дає приємний UX: користувач не зобов’язаний писати лапки, якщо не хоче. Але й тут контракт має бути строгим: якщо NArg() повертає 0, значить заголовка немає — і це помилка використання.

8. Міні-схема розбору argv усередині команди

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

flowchart TD
    A["argv команди: []string"] --> B["fs.Parse(argv)"]
    B -->|err != nil| U[повертаємо ErrUsage + помилку парсингу]
    B -->|ok| C["fs.NArg() / fs.Args()"]
    C -->|контракт не збігся| V[повертаємо ErrUsage + рядок usage]
    C -->|збігся| D[парсимо типи: Atoi / тощо]
    D -->|type error| W["повертаємо ErrUsage + 'ID має бути int'"]
    D -->|ok| E[виконуємо команду]
    E --> F[повертаємо nil]

Сенс цієї схеми простий: спочатку ми приводимо вхід до ладу (Parse), потім перевіряємо контракт (NArg), потім парсимо типи (Atoi), і лише тоді робимо роботу. Це прямий шлях до того, щоб CLI поводився передбачувано, а не як «лотерея імені випадкового індексу».

9. Типові помилки під час розбору позиційних аргументів і парсингу

Помилка №1: читати позиційні аргументи з argv, а не з fs.Args().
Такий код часто «працює» на прикладах без прапорців, а потім раптово ламається, коли з’являється -force або будь-який інший прапорець. Проблема в тому, що argv — сирий вхід, а fs.Args() — уже результат розбору, де прапорці відокремлено. Тому спочатку Parse, потім fs.Args() — і нервова система скаже вам дякую.

Помилка №2: викликати fs.Arg(0) без перевірки fs.NArg().
Якщо користувач забув аргумент, програма полізе у Arg(0) і отримає порожній рядок або почне парсити «ніщо» як число. У найкращому разі буде незрозуміла помилка, у найгіршому — поведінка, схожа на «все пройшло, але чому id=0?». Правильніше спочатку перевірити кількість аргументів як частину контракту команди.

Помилка №3: мовчки ігнорувати зайві аргументи.
Команда done 12 extra має або явно прийняти extra як частину сенсу (що дивно для done), або чесно сказати: «забагато аргументів, usage: …». Мовчазне ігнорування перетворює CLI на вгадування: користувач думає, що extra враховано, а ваша програма просто зробила вигляд, що його немає.

Помилка №4: повертати «голий» strconv.Atoi без контексту.
Якщо ви просто робите return strconv.Atoi(fs.Arg(0)), користувач отримає повідомлення рівня «invalid syntax» і гадатиме, що саме не так. Значно краще додати контекст: який аргумент ви очікували, яке значення отримали і що мало бути. Це особливо корисно, коли команда приймає кілька чисел.

Помилка №5: не розрізняти помилки використання та помилки виконання.
Коли Parse падає, коли аргументів не вистачає, коли id не число — це майже завжди помилка використання, яку треба маркувати як ErrUsage, щоб верхній рівень міг коректно вибрати exit code і, за бажання, показати usage. Ідея «помилки як значення» та практика обгортання причин через %w допомагають будувати таку класифікацію без аналізу тексту.

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