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() — как очищенные и готовые к готовке.

Покажу на маленьком примере «сырое vs очищенное»:

package main

import (
	"flag"
	"fmt"
)

func demo(argv []string) {
	fs := flag.NewFlagSet("demo", flag.ContinueOnError)
	_ = fs.Bool("force", false, "force mode")

	_ = fs.Parse(argv)

	fmt.Println("raw argv:", argv)       // raw argv: [-force 12]
	fmt.Println("pos args:", fs.Args())  // pos 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 must be integer, got %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
}

Мы снова используем wrapping через %w, чтобы ErrUsage не потерялся в цепочке причин. Это соответствует общему подходу «оборачиваем ошибку, но не теряем исходную причину».

6. Ошибки парсинга флагов и классификация ошибок

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

Поэтому в обработчике команды есть стандартный приём: если Parse вернул ошибку, мы заворачиваем её в ErrUsage и возвращаем наверх. Мы не печатаем её здесь (печатать будет верхний уровень), но классифицируем. И снова — wrapping.

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, "mark done even if already done (demo)")

	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("done: id=%d force=%v\n", id, *force) // done: 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 must be integer, got %q", s)
	}
	return id, nil
}

Здесь важно, что fs.Arg(0) мы вызываем только после requireNArgs. Это как пристёгиваться перед тем, как тронуться: можно и без, но потом будет очень обидно и очень громко.

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

Команда add обычно добавляет задачу с текстом. Частая UX-модель: всё, что осталось после флагов — это и есть текст задачи. То есть поддерживаем такие варианты:

app add "Buy milk"
app add -priority=2 Buy milk today

Тут удобнее взять 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, "task priority (demo)")

	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("add: %q (priority=%d)\n", title, *priority) // add: "Buy milk today" (priority=2)
	return nil
}

Этот подход даёт приятный UX: пользователь не обязан писать кавычки, если не хочет. Но и тут контракт должен быть строгим: если NArg() возвращает 0, значит заголовка нет — и это ошибка использования.

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

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

flowchart TD
    A["argv команды: []string"] --> B["fs.Parse(argv)"]
    B -->|err != nil| U[return ErrUsage + parse error]
    B -->|ok| C["fs.NArg() / fs.Args()"]
    C -->|не совпал контракт| V[return ErrUsage + usage string]
    C -->|совпал| D[parse types: Atoi / etc]
    D -->|type error| W["return ErrUsage + 'id must be int'"]
    D -->|ok| E[выполняем команду]
    E --> F[return 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 помогают строить такую классификацию без анализа текста.

1
Задача
Go SELF, 49 уровень, 2 лекция
Недоступна
Никнейм с префиксом
Никнейм с префиксом
1
Задача
Go SELF, 49 уровень, 2 лекция
Недоступна
Умножить параметр
Умножить параметр
1
Задача
Go SELF, 49 уровень, 2 лекция
Недоступна
Склейка слов
Склейка слов
1
Задача
Go SELF, 49 уровень, 2 лекция
Недоступна
Две команды задач
Две команды задач
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ