Skip to content

Goのインターフェイス設計

このメモは昔の記事から取り込んだので公開用の文体になっている。

こないだ同僚から、Goの interface について聞かれて、自分自身も良い気づきを得たので文章にしておこうと思いました。この文章は、Goのinterfaceは1つまたは2つ程度のメソッドしか持たないことが多い という文が納得できない人に読んでほしいと思っています。

説明のため、以下のような仕様を考えます。

  • 複数のToDoサービスを集約してコンソールに出力するコマンドを作る
  • ToDoリストはテキストとJSONの2通りで出力可能にする
  • シンプルなToDoサービスAPIは以下の値を提供している
    • タイトル
    • 完了フラグ
  • 多機能なToDoサービスAPIは以下の値を提供している
    • タイトル
    • 完了フラグ
    • 開始日
    • 期限
    • URL
    • メモ

必要な実装としては以下のようになるでしょう。

  • 2つのサービスからToDoを取得する
  • 取得したToDoをテキストで出力する
  • 取得したToDoをJSONで出力する

上記で挙げた仕様について、どのように考えるでしょうか。

他の言語では「2つのToDoサービスは Service インターフェイスで表現して、個々のToDoは Task インターフェイスで…」のように、まずインターフェイスから考えることがあるかもしれません。しかし、Goは型の階層を持たない言語なので interface はよくあるOOP言語のような使い方をするものではありません。Goの interface は、基本的に 使う側が定義するもの です。CodeReviewガイドラインにも書かれています。

まずは、どのように使うかを考えましょう。ToDoリストをテキストで出力することを考えてみます。

// よくない例
type Task interface {
Title() string // タイトル
Done() bool // 完了フラグ
Due() time.Time // 期限
}
func OutputText(tasks []Task) {
for _, task := range tasks {
if task.Done() {
fmt.Printf("- [x] %s\n", task.Title())
} else {
fmt.Printf("- [ ] %s\n", task.Title())
}
}
}
// package simpletodo
type Task struct {}
func (t *Task) Title() string { return t.title }
func (t *Task) Done() bool { return t.done }
func (t *Task) Due() time.Time { return time.Time{} } // ??

この書き方は、片方のAPIには存在しないにも関わらず実装しなければならないし、他のToDoサービスが現れた場合に矛盾しないよう interface も修正しなければならなくなるのであまり良くありません。

やり方はいくつもあると思いますが、こういった場合に私が良くやる方法は、Goの標準ライブラリで使われているインターフェイスを真似る方法です。具体的には io.WriterTo インターフェイスが定義されているので、これを真似してみましょう。本物の io.WriterToWriteTo(w io.Writer) (int64, error) シグネチャですが、ここでは簡略化しています。

type Task interface {
WriteTo(io.Writer)
}
func OutputText(tasks []Task) {
for _, task := range tasks {
task.WriteTo(os.Stdout)
}
}
// package simpletodo
type Task struct {}
func (t *Task) WriteTo(w io.Writer) {
if t.Done {
fmt.Fprintf(w, "- [x] %s\n", task.Title)
} else {
fmt.Fprintf(w, "- [ ] %s\n", task.Title)
}
}
// package easytodo
type Task struct {}
func (t *Task) WriteTo(w io.Writer) {
if t.Done {
fmt.Fprintf(w, "- [x] %s(%v)\n", task.Title, task.Due)
} else {
fmt.Fprintf(w, "- [ ] %s(%v)\n", task.Title, task.Due)
}
if t.Note != "" {
fmt.Fprintf(w, "\t%s\n", task.Note)
}
}

こうすると、それぞれのToDoサービスで WriteTo を実装していて少々冗長に感じますが、ToDoサービスが変更されても新しく追加しても、呼び出し側は変更しなくても良くなりました。

次にJSONで出力する実装ですが、これも標準ライブラリに json.Marshaler があるので真似てもいいのですけど、Goのインターフェイスが面白いところはシグネチャが合っていればなんでも良いところだと思うので、以下のようにJSONで出力する WriteTo を満たす型を用意してみました。

type Task struct {
Title string `json:"title"`
Done string `json:"done"`
Due time.Time `json:"due,omitempty"`
}
type InJSON Task
func (t *InJSON) WriteTo(w io.Writer) {
json.NewEncoder(w).Encode(t)
}

このように使います

var tasks []WriterTo
for _, task := range simpletodo.Tasks() {
tasks = append(tasks, simpletodo.InJSON(task))
}
for _, task := range easytodo.Tasks() {
tasks = append(tasks, easytodo.InJSON(task))
}
Output(tasks)

結果として、サービス側にはインターフェイスがなくなりますし、メソッドも WriteTo だけになって、Goらしくなりました。