Go の flag パッケージってとても便利ですよね。 CLI を作成するさいによくお世話になると思います。 しかし、bool のフラグに対して関数を渡せないのが不便です。今回はそれを解決する方法を紹介します。

※go1.17 時点の方法のため、go のバージョンアップで使用できなくなる可能性があります。

bool 値を渡せないとは?

flag パッケージには、flag.Funcというコールバック関数を受け取るものが用意されています。

func Func(name, usage string, fn func(string) error)

使い方は以下のような形です。

package main

import (
	"flag"
	"log"
)

func main() {
	var arg string
	flag.Func("hello", "set string", func(s string) error {
		arg = s
		return nil
	})
	flag.Parse()
	log.Print(arg)
}
$ go run flag.go -hello ok
2022/01/04 22:30:16 ok
$ go run flag.go -hello ok -hello override
2022/01/04 22:30:23 override

このコールバック関数で渡されている string の s は見ての通り実行時にフラグに対して指定した値になります。

しかし、上記の関数は bool 値には使えません。なぜなら bool のフラグは引数を取らないからです。

func main() {
	good := flag.Bool("good", false, "good")
	flag.Parse()
	log.Print(*good)
}
$ go run flag.go
2022/01/04 22:50:07 false
$ go run flag.go -good
2022/01/04 22:50:09 true

じゃあ渡せないの?ということではありません。

bool のフラグにコールバック関数を渡す方法

前提知識として、go におけるフラグの扱いを見ていきます。

go の flag パッケージの引数は様々な型に対応していますが、どの関数も基本的には flag.Varのラッパーです。

func Var(value Value, name string, usage string)

この flag.Valueを満たすように値をラップして渡すということをやっています。

// https://cs.opensource.google/go/go/+/refs/tags/go1.17.5:src/flag/flag.go;l=793;drc=refs%2Ftags%2Fgo1.17.5
func Float64Var(p *float64, name string, value float64, usage string) {
	CommandLine.Var(newFloat64Value(value, p), name, usage)
}

そうです。勿論 bool 値の時もラップしています。ではなぜ bool の時は挙動が変わるのでしょうか?

// https://cs.opensource.google/go/go/+/refs/tags/go1.17.5:src/flag/flag.go;l=637;drc=refs%2Ftags%2Fgo1.17.5
unc BoolVar(p *bool, name string, value bool, usage string) {
	CommandLine.Var(newBoolValue(value, p), name, usage)
}

その秘密はパース処理にあります。以下の処理はflag.Parseの内部処理です。

// https://cs.opensource.google/go/go/+/refs/tags/go1.17.5:src/flag/flag.go;l=966-976;drc=refs%2Ftags%2Fgo1.17.5
	if fv, ok := flag.Value.(boolFlag); ok && fv.IsBoolFlag() { // special case: doesn't need an arg
		if hasValue {
			if err := fv.Set(value); err != nil {
				return false, f.failf("invalid boolean value %q for -%s: %v", value, name, err)
			}
		} else {
			if err := fv.Set("true"); err != nil {
				return false, f.failf("invalid boolean flag %s: %v", name, err)
			}
		}
	} else {

この処理で分かるように、boolFlag にキャスト可能な interface の時は bool 用の処理をすることがわかります。 そして、boolFlag の interface は以下です。

// https://cs.opensource.google/go/go/+/refs/tags/go1.17.5:src/flag/flag.go;drc=refs%2Ftags%2Fgo1.17.5;l=133
type boolFlag interface {
	Value
	IsBoolFlag() bool
}

ここまで分かれば、コールバック関数を使える bool フラグを作ることなんて御茶の子さいさいです。

package main

import (
	"flag"
	"log"
)

type boolFunc func(string) error

func (boolFunc) String() string {
	return "boolFunc"
}

func (f boolFunc) Set(s string) error {
	return f(s)
}

func (boolFunc) IsBoolFlag() bool {
	return true
}

func main() {
	var reverse bool
	flag.Var(boolFunc(func(_ string) error {
		reverse = !reverse
		return nil
	}), "reverse", "")
	flag.Parse()
	log.Print(reverse)
}
$ go run flag.go
2022/01/04 23:15:17 false
$ go run flag.go -reverse
2022/01/04 23:15:20 true
$ go run flag.go -reverse -reverse
2022/01/04 23:15:23 false

注意

公開されていない interface のため、将来的に挙動が変更される可能性があります。 あくまで現在のバージョン(1.17)で使用できるハックとして捉えてもらえると助かります。