1. Зачем пакет flag, если есть os.Args
Когда вы пишете CLI-программу, вы почти всегда хотите, чтобы пользователь мог настраивать поведение: включить режим отладки, задать лимит, выбрать формат вывода. Можно, конечно, разбирать os.Args руками, но это быстро начинает напоминать разбор чемодана после отпуска: «откуда тут ещё три пакета и почему носки в холодильнике?». Пакет flag — стандартный способ разбора флагов в Go, который даёт единый синтаксис и снимает часть рутины.
Фундаментальная идея такая: os.Args — это просто сырой []string «как ОС передала», а flag умеет превращать его в понятные значения нужных типов. В итоге вы получаете аккуратные string/int/bool, а не вечный праздник парсинга: «а тут было -limit=10 или -limit 10, а вдруг -limit ten?».
Внутренне flag всё равно смотрит на аргументы запуска (по умолчанию это os.Args[1:]), но делает это централизованно, одинаково для всех флагов, и по стандартным правилам.
Наглядно это можно представить так:
flowchart LR
A["os.Args (сырой []string)"] --> B["flag.Parse()"]
B --> C["значения флагов: string/int/bool"]
B --> D["позиционные аргументы: flag.Args()"]
2. Объявление флагов
Когда вы впервые видите API flag, он кажется странным: почему flag.String объявляет флаг, а не читает? Почему оно возвращает указатель? И почему нельзя просто написать «прочитай мне флаги» одной командой? Всё это — особенности модели flag: сначала мы описываем, какие флаги поддерживает программа, потом вызываем разбор, и только потом читаем результаты.
flag.String, flag.Int, flag.Bool
Флаги объявляются функциями семейства:
- flag.String(name, defaultValue, usage)
- flag.Int(name, defaultValue, usage)
- flag.Bool(name, defaultValue, usage)
Каждая из них возвращает указатель на значение (*string, *int, *bool). Почему так — разберём чуть ниже, а пока запомним практическое правило: после Parse() читаем значение через *.
Небольшая таблица, чтобы глазам было проще:
| Функция | Тип значения | Что возвращает |
|---|---|---|
|
|
|
|
|
|
|
|
|
Мини-пример: объявим три флага для нашего учебного «псевдо-todo» (пока он ничего не хранит — мы просто печатаем, что бы сделали).
package main
import (
"flag"
"fmt"
)
func main() {
title := flag.String("title", "", "task title")
priority := flag.Int("priority", 1, "priority 1..5")
done := flag.Bool("done", false, "mark task as done")
flag.Parse()
fmt.Println(*title, *priority, *done)
// (пример вывода зависит от аргументов запуска)
}
Обратите внимание: мы не трогаем os.Args руками вообще. Это и есть первая «победа» над хаосом.
Почему возвращается *T
Когда начинающий видит *title, он часто думает: «Я уже просил просто прочитать строку, почему мне дали… адрес строки?». Вопрос нормальный. Ответ — в том, что flag сначала создаёт «контейнер» под значение, а потом во время Parse() записывает туда результат разбора аргументов.
Можно представить это как табличку на рабочем столе:
- title — это карточка, на которой написано, где лежит реальное значение
- *title — это само значение, лежащее по этому адресу
То есть flag.String(...) возвращает не строку, а «место, куда потом положат строку». И после flag.Parse() это место оказывается заполнено либо тем, что ввёл пользователь, либо значением по умолчанию.
Мини-демонстрация, где мы печатаем типы, чтобы мозгу стало спокойнее:
package main
import (
"flag"
"fmt"
)
func main() {
title := flag.String("title", "untitled", "task title")
fmt.Printf("%T\n", title) // *string
flag.Parse()
fmt.Printf("%T\n", *title) // string
}
Мы здесь не углубляемся в указатели как тему (это будет отдельная большая история), но важно привыкнуть к одной привычке: значение флага всегда читаем через *.
3. Разбор аргументов: flag.Parse()
В flag есть один почти ритуальный порядок действий: «объявили → распарсили → прочитали». И если этот порядок нарушить, программа будет работать так же уверенно, как будильник без батарейки: вроде вещь знакомая, но пользы мало.
flag.Parse() делает несколько вещей сразу. Он берёт аргументы запуска, распознаёт среди них известные флаги, преобразует значения в нужные типы (например, строку "10" в int(10) для flag.Int), и складывает результат в те самые переменные-указатели, которые вы получили при объявлении флагов. Всё, что не относится к флагам, он оставляет как «остаток» — позиционные аргументы.
Простой пример того, что будет, если забыть Parse(). (Это типичная ошибка, поэтому полезно один раз увидеть.)
package main
import (
"flag"
"fmt"
)
func main() {
limit := flag.Int("limit", 10, "max items")
fmt.Println(*limit) // 10 (всегда 10, пока не сделали Parse)
}
А вот правильный вариант:
package main
import (
"flag"
"fmt"
)
func main() {
limit := flag.Int("limit", 10, "max items")
flag.Parse()
fmt.Println(*limit) // зависит от -limit ...
}
Важный нюанс: flag поддерживает оба популярных синтаксиса передачи значений: -limit=5 и -limit 5. Вам не нужно писать два парсера и гадать, какой вариант выбрал пользователь.
4. Позиционные аргументы
Флаги — это именованные настройки («включи вот это», «поставь лимит такой-то»). Но во многих командах есть ещё и позиционные аргументы: файл, путь, имя сущности, текст задачи. Они идут без имени и определяются порядком.
После flag.Parse() вы можете взять позиционные аргументы тремя способами:
- flag.Args() вернёт []string со всеми позиционными аргументами
- flag.NArg() вернёт количество позиционных аргументов
- flag.Arg(i) вернёт i-й позиционный аргумент (с нуля)
Давайте сделаем нашу «todo-команду» чуть более живой: пусть заголовок задачи будет позиционным аргументом, а приоритет — флагом. То есть запуск выглядит примерно так:
- todo -priority 3 "Buy milk"
Код:
package main
import (
"flag"
"fmt"
)
func main() {
priority := flag.Int("priority", 1, "priority 1..5")
flag.Parse()
fmt.Println("args:", flag.Args()) // args: [...]
fmt.Println("priority:", *priority) // priority: ...
}
Проверяйте flag.NArg() перед flag.Arg(0)
Теперь добавим чтение «заголовка» как первого позиционного аргумента, но аккуратно: сначала проверим, что он вообще есть.
package main
import (
"flag"
"fmt"
)
func main() {
flag.Parse()
if flag.NArg() < 1 {
fmt.Println("missing TITLE")
return
}
fmt.Println("title:", flag.Arg(0))
}
Здесь очень важная привычка: не читать flag.Arg(0) вслепую. Если аргументов нет, вы получите пустую строку, и программа может «как будто работать», но делать бессмысленные вещи. Это хуже, чем явная ошибка, потому что такие баги живут долго и питаются вашей уверенностью.
5. Полезные нюансы CLI
Булевы флаги: flag.Bool
Булевы флаги — это отдельная радость CLI. В отличие от строк и чисел, они часто используются как «переключатель режима»: включить/выключить что-то. И хороший CLI обычно позволяет писать их коротко.
В flag.Bool есть практичный UX-момент: флаг можно задавать как -done=true, но обычно можно и просто -done, что означает true. Это делает команды короче и приятнее.
Пусть в нашем «todo» будет флаг -done, который помечает задачу выполненной (да, странно создавать задачу уже выполненной — но CLI, как и жизнь, не обязаны быть логичными).
package main
import (
"flag"
"fmt"
)
func main() {
done := flag.Bool("done", false, "mark task as done")
flag.Parse()
fmt.Println("done:", *done) // done: true/false
}
Если пользователь не передал -done, то значение останется дефолтным (false). Если передал — станет true. И, что важно, вам не нужно руками разбирать строку "true"/"false" и думать, что делать с "yes".
Терминатор --: «дальше это не флаги»
Есть одна маленькая CLI-проблема, которая всплывает внезапно. Представьте, что позиционный аргумент сам начинается с -. Например, вы хотите передать текст задачи -buy milk (может, это стиль такой) или файл с именем -data.txt (да, такое бывает).
Без дополнительного правила flag попытается интерпретировать это как флаг. Чтобы явно сказать «всё, дальше не флаги, дальше просто аргументы», используют терминатор --.
С точки зрения пользователя команда выглядит так:
- todo -priority 2 -- -buy milk
Код при этом не меняется: вы всё так же вызываете flag.Parse(), а потом читаете flag.Args(). Просто -- помогает парсеру корректно разделить части.
Мини-пример, который показывает сам факт «после -- всё идёт в Args»:
package main
import (
"flag"
"fmt"
)
func main() {
flag.Parse()
fmt.Println(flag.Args()) // всё после -- будет здесь
}
Мини-пример: собираем «конфиг запуска»
Сейчас мы соберём небольшой цельный кусочек: флаги + позиционный аргумент, и выведем, что именно программа поняла. Это ещё не «настоящее todo-приложение», но это уже корректный слой «интерфейса запуска».
Условия простые: priority и done — флаги, TITLE — позиционный аргумент.
package main
import (
"flag"
"fmt"
)
func main() {
priority := flag.Int("priority", 1, "priority 1..5")
done := flag.Bool("done", false, "mark as done")
flag.Parse()
title := ""
if flag.NArg() >= 1 {
title = flag.Arg(0)
}
fmt.Println("title:", title, "priority:", *priority, "done:", *done)
}
Да, здесь мы пока не валидируем диапазон приоритета и не печатаем usage красиво — это отдельные навыки. На этом этапе важно довести до автоматизма механику: объявил → Parse → прочитал → посмотрел на Args.
6. Типичные ошибки
Ошибка №1: забыли вызвать flag.Parse() и удивляются, почему «флаги не работают».
Это самая частая история. Программа запускается, ничего не падает, но любое -priority 5 будто игнорируется. Причина проста: пока не вызвали Parse(), у вас в переменных лежат только значения по умолчанию.
Ошибка №2: пытаются использовать title вместо *title и получают странные ошибки компиляции.
flag.String возвращает *string, а не string. Это сделано затем, чтобы Parse() мог записать результат в заранее подготовленное место. Лечится просто: после Parse() всегда разыменовывайте *title, *limit, *done.
Ошибка №3: объявляют флаги после flag.Parse() и ожидают, что они будут распознаны.
Parse() разбирает аргументы на основе списка уже объявленных флагов. Если вы объявили флаг позже, Parse() про него не «узнает», потому что разбор уже произошёл. Правило очень жёсткое: сначала объявления, потом Parse().
Ошибка №4: читают позиционные аргументы до Parse() и получают «пусто/не то».
До Parse() пакет flag ещё не отделил флаги от позиционных аргументов. Поэтому flag.Args() до разбора либо будет пустым, либо будет содержать неожиданные вещи. Правильный порядок: flag.Parse() и только потом flag.Args()/flag.NArg()/flag.Arg(i).
Ошибка №5: читают flag.Arg(0) без проверки flag.NArg() и получают «тихую» неправильную логику.
В отличие от чтения os.Args[i], здесь вы обычно не получите панику, но получите пустую строку, а дальше программа может создать задачу с пустым названием и сделать вид, что так и задумано. Перед чтением позиционных аргументов полезно проверять количество, даже если очень хочется «сэкономить две строчки».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ