3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GoAdvent Calendar 2024

Day 18

Go言語flagライブラリのクリーンな使い方

Last updated at Posted at 2024-12-17

reentrancy (再入可能性) の欠如

Go 言語の標準ライブラリの一つである flag の使用例として挙げられる次のコードですが、

コマンドライン引数をパースする
flag.Parse()

あなたが設計品質にこだわるプログラマならば、見た瞬間に「よい設計ではない」と思うはずです。なぜなら、この一行だけで reentrancy (再入可能性) に欠けることが分かるからです。

reentrancy に欠けると、動作が実行時の外部状態・内部状態の影響を受けてしまい、処理結果の再現性を保証できません。不具合の温床となります。

具体的に言うと、Parse() 関数が引数を取らないので、関数呼び出し時に(1)パース対象の文字列群と(2)認識対象のコマンドライン引数群に関する設定を渡すことができません。そのため、Parse() 関数の実装が何らかのグローバルまたはパッケージグローバルな変数を参照していることを推測できます。

実際、Parse() 関数の実装は、パース対象の文字列群として os.Args を参照し、認識対象のコマンドライン引数群に関する設定は flag.CommandLine に保存します。

認識対象のコマンドライン引数群を設定する方法として挙げられている次の例も、

コマンドライン引数設定例1
var nFlag = flag.Int("n", 1234, "help message for flag n")
コマンドライン引数設定例2
flag.IntVar(&flagvar, "flagname", 1234, "help message for flagname")

プログラムが汚くなることを助長するのは間違いありません。コマンドライン引数毎にグローバル変数が作られ、プログラムコード全体でそれらの変数群をグローバル参照することになるでしょう。

コマンドライン引数処理の理想型

人それぞれ考え方は異なりますが、私の価値観ではコマンドライン引数の処理は次のような形になることが理想型です。

コマンドライン引数処理の理想型
package main

import (
    "example.com/options"
)

func main() {
    // Parse the command-line arguments.
    opts := options.Parse(os.Args)
}

要点は次の通りです。

  1. 認識可能なコマンドライン引数群に関する設定を一つのパッケージ(この例では example.com/options)内に隠蔽する。→ パース処理実行前にパース処理の設定のためのコードをごちゃごちゃ書かなくて済むので呼び出し側のコードがすっきりし、また、設定が局所化されるのでメンテナンス性が高まる。

  2. パース対象の文字列群をパース処理実行時に明示的に渡す(この例では os.ArgsParse([]string) 関数に渡していて、勝手に os.Args を参照しない)。→ 実装が reentrant になり、実行時の外部・内部状態に処理結果が左右されなくなる。結果、コードが頑健になり、また、コードのフローを追いやすくなる。

  3. パース結果を一つの struct への参照として受け取る。→ コマンドライン引数毎に変数を作らずに一箇所にまとめることにより、パース結果が取り回しやすくなる。関数の引数としても渡せるので、グローバル変数化を避けられる。

実装

flag ライブラリの NewFlagSet 関数を活用することで、reentrant な Parse(arguments []string) 関数を実装することができます。

実際に実装してみましょう。

まず、options パッケージを作成し、Parse(arguments []string) 関数を実装します。この実装では、コマンドライン引数 -my-string {string} をサポートします。

~$ mkdir -p sample/options
~$ cd sample/options
~/sample/options$ go mod init example.com/options
go: creating new go.mod: module example.com/options
~/sample/options$ code options.go
options.go
package options

import (
	"flag"
)

// A struct to hold parameters passed via command-line arguments.
type Options struct {
	MyString string
}

// Parse command-line arguments.
func Parse(arguments []string) *Options {
	options := Options{}

	// Create a command-line argument parser (= an instance of FlagSet).
	flagset := createParser(arguments, &options)

	// Parse the command line arguments.
	flagset.Parse(arguments[1:])

	// Return the parsed result.
	return &options
}

func createParser(arguments []string, options *Options) *flag.FlagSet {
	// Create a command-line argument parser.
	flagset := flag.NewFlagSet(arguments[0], flag.ExitOnError)

	// -my-string string
	flagset.StringVar(&options.MyString, "my-string", "",
 		"specifies my string.")

	return flagset
}

次に、main パッケージに、options.Parse(arguments []string) 関数を呼び出す main 関数を実装します。

~/sample/options$ cd ..
~/sample$ go mod init example.com/sample
go: creating new go.mod: module example.com/sample
go: to add module requirements and sums:
	go mod tidy
~/sample$ code main.go
main.go
package main

import (
	"fmt"
	"os"

	"example.com/options"
)

func main() {
	// Parse the command-line arguments.
	opts := options.Parse(os.Args)

	// Print the parsed result.
	fmt.Printf("opts = %+v\n", opts)
}

モジュールの依存関係を更新し、

~/sample$ go mod edit -replace example.com/options=./options
~/sample$ go mod tidy
go: found example.com/options in example.com/options v0.0.0-00010101000000-000000000000

プログラムを実行します。

~/sample$ go run main.go -my-string hello
opts = &{MyString:hello}

応用 (time.Time)

前のセクションの実装は、文字列型の引数を取る -my-string オプションをサポートしています。このオプションのため、flag.FlagSetStringVar メソッドを利用しました。

同様にして、BoolVar メソッドや IntVar メソッドを利用することで、真偽値型や整数型の引数を取るオプションをサポートすることができます。

しかし、flag.FlagSet がサポートしていない型を引数に取るオプションをサポートするためには一手間必要です。ここでは、yyyy-MM-dd という書式で日付を受け取る -my-date オプションを定義し、受け取った文字列を time.Time 型の変数に変換する処理を追加実装してみます。

実装の要点は、time.Time 用に flag.Value インターフェースを実装し、flag.FlagSetVar メソッドを利用することです。

options.go
package options

import (
	"flag"
	"time"
)

// The definition of this 'timeValue' and its associated methods constitute
// an implementation of the 'flag.Value' interface for 'time.Time'.
type timeValue time.Time

func (t *timeValue) String() string { return (*time.Time)(t).String() }
func (t *timeValue) Get() any       { return time.Time(*t) }
func (t *timeValue) Set(s string) error {
	// Try to parse the given string using the "yyyy-MM-dd" format.
	v, err := time.Parse(time.DateOnly, s)

	// If the string was parsed successfully.
	if err == nil {
		*t = timeValue(v)
	}

	return err
}

// A struct to hold parameters passed via command-line arguments.
type Options struct {
	MyString string
	MyDate   time.Time
}

// Parse command-line arguments.
func Parse(arguments []string) *Options {
	options := Options{}

	// Create a command-line argument parser (= an instance of FlagSet).
	flagset := createParser(arguments, &options)

	// Parse the command line arguments.
	flagset.Parse(arguments[1:])

	// Return the parsed result.
	return &options
}

func createParser(arguments []string, options *Options) *flag.FlagSet {
	// Create a command-line argument parser.
	flagset := flag.NewFlagSet(arguments[0], flag.ExitOnError)

	// -my-string string
	flagset.StringVar(&options.MyString, "my-string", "",
		"specifies my string.")

	// -my-date yyyy-MM-dd
	flagset.Var((*timeValue)(&options.MyDate), "my-date",
		"specifies my date in the yyyy-MM-dd format.")

	return flagset
}

実行結果は次のようになります。

~/sample$ go run main.go -my-string hello -my-date 2025-01-01
opts = &{MyString:hello MyDate:2025-01-01 00:00:00 +0000 UTC}

動けばいい人

ソフトウェアの設計にこだわりがなく、「プログラムは動けばいい」という考えの人は、わざわざ options パッケージなど定義せず、次のようなコードを書くでしょう。

~$ mkdir sample2
~$ cd sample2
~/sample2$ code main.go
main.go
package main

import (
	"flag"
	"fmt"
)

var myString string

func main() {
	flag.StringVar(&myString, "my-string", "", "specifies my string.")
	flag.Parse()

	fmt.Printf("myString = %v\n", myString)
}
~/sample2$ go mod init example.com/sample
go: creating new go.mod: module example.com/sample
go: to add module requirements and sums:
	go mod tidy
~/sample2$ go mod tidy
~/sample2$ go run main.go -my-string hello
myString = hello

実際これで動きますし、コード量も少ないので、「開発工数諸々を考慮すれば、むしろこっちのほうが良い」と積極的に主張しさえするでしょう。

しかしながら、「flag.Parse() は reentrant ではないし、コードの可読性は低くなるし、グローバル変数は増えるし、将来のオプション追加を見据えるとメンテナンス性も悪いから、flag.NewFlagSet 関数を用いて options パッケージを実装せざるをえない」という感覚を持てるか否か、また、その感覚を信じて実際にコードに落とし込む労力を割くかどうかは、再利用可能なソフトウェアを書けるソフトウェアエンジニアになれるかどうかの分かれ目です。

設計品質にこだわらないコーディングを何年続けても、「アプリやシステムなどの一品物のソフトウェアを書くスキル」は身に付いたとしても「ライブラリやフレームワークなどの再利用可能なソフトウェアを書くスキル」は身に付きません。

おわりに

いつか起業したいエンジニアへ』という記事の最後でも述べておりますが、再利用可能なソフトウェアを書けるかどうかはプログラマの人生を大きく左右します。flag.Parse() を見て「よい設計ではない」と思った感覚を大切にし、設計品質にこだわるコーディングを続けてください。

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?