reentrancy (再入可能性) の欠如
Go 言語の標準ライブラリの一つである flag の使用例として挙げられる次のコードですが、
flag.Parse()
あなたが設計品質にこだわるプログラマならば、見た瞬間に「よい設計ではない」と思うはずです。なぜなら、この一行だけで reentrancy (再入可能性) に欠けることが分かるからです。
reentrancy に欠けると、動作が実行時の外部状態・内部状態の影響を受けてしまい、処理結果の再現性を保証できません。不具合の温床となります。
具体的に言うと、Parse()
関数が引数を取らないので、関数呼び出し時に(1)パース対象の文字列群と(2)認識対象のコマンドライン引数群に関する設定を渡すことができません。そのため、Parse()
関数の実装が何らかのグローバルまたはパッケージグローバルな変数を参照していることを推測できます。
実際、Parse()
関数の実装は、パース対象の文字列群として os.Args
を参照し、認識対象のコマンドライン引数群に関する設定は flag.CommandLine
に保存します。
認識対象のコマンドライン引数群を設定する方法として挙げられている次の例も、
var nFlag = flag.Int("n", 1234, "help message for flag n")
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)
}
要点は次の通りです。
-
認識可能なコマンドライン引数群に関する設定を一つのパッケージ(この例では
example.com/options
)内に隠蔽する。→ パース処理実行前にパース処理の設定のためのコードをごちゃごちゃ書かなくて済むので呼び出し側のコードがすっきりし、また、設定が局所化されるのでメンテナンス性が高まる。 -
パース対象の文字列群をパース処理実行時に明示的に渡す(この例では
os.Args
をParse([]string)
関数に渡していて、勝手にos.Args
を参照しない)。→ 実装が reentrant になり、実行時の外部・内部状態に処理結果が左右されなくなる。結果、コードが頑健になり、また、コードのフローを追いやすくなる。 -
パース結果を一つの
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
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
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.FlagSet
の StringVar
メソッドを利用しました。
同様にして、BoolVar
メソッドや IntVar
メソッドを利用することで、真偽値型や整数型の引数を取るオプションをサポートすることができます。
しかし、flag.FlagSet
がサポートしていない型を引数に取るオプションをサポートするためには一手間必要です。ここでは、yyyy-MM-dd
という書式で日付を受け取る -my-date
オプションを定義し、受け取った文字列を time.Time
型の変数に変換する処理を追加実装してみます。
実装の要点は、time.Time
用に flag.Value
インターフェースを実装し、flag.FlagSet
の Var
メソッドを利用することです。
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
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()
を見て「よい設計ではない」と思った感覚を大切にし、設計品質にこだわるコーディングを続けてください。