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 есть три главных способа работать с позиционными аргументами. Они похожи, но подходят для разных ситуаций. Думайте об этом как о трёх режимах «посмотреть на очередь»: можно узнать длину, взять элемент по индексу или получить целый список.
Сводная табличка:
| Метод | Что даёт | Когда удобно |
|---|---|---|
|
количество позиционных аргументов | когда команда требует «ровно N» |
|
позиционный аргумент по индексу | когда нужен конкретный аргумент, например ID |
|
слайс всех позиционных аргументов | когда аргументов много или вы хотите перебрать их циклом |
В нашем 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 помогают строить такую классификацию без анализа текста.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ