structの生成にfactory的な関数を用意しておくと埋め込みに変えたときに特に嬉しいという話

はじめに

structの生成にfactory的な関数を用意しておくと埋め込みに変えたときに特に嬉しいという話。これはもしかしたらニッチな話かもしれない。あるいはgoの本のどこにでも書いてある一般的な話かもしれない。今日goのコードを書いていてなるほどなーと思ったことだったので。備忘録として書いてみる。

はじめは1つのstructだった

はじめは1つのstructだった。例えば以下のようなstruct。何かしらのjobを模したものだと思って欲しい。

// Job :
type Job struct {
    Name       string
    Status     Status
    RetryAt    *time.Time
    CanceledAt *time.Time
    FinishedAt *time.Time
}

// Status :
type Status int

// Status :
const (
    Waiting Status = iota
    Running
    Canceled
    Done
)

初めはwaiting状態のstatusがRunning状態になったあとDoneの状態になる。そういうようなstruct。

時には失敗することもある

常にジョブの実行が成功するわけではない。時には失敗することもある。そのようなときにはエラーの発生時刻やどのようなエラーが発生したかの情報がほしいかもしれない。LastErrorStringErrorAt を追加してみる。例えばこのような感じ。

@@ -15,10 +15,12 @@
 
 // Job :
 type Job struct {
-  Name       string
-  Status     Status
-  RetryAt    *time.Time
-  CanceledAt *time.Time
-  FinishedAt *time.Time
+   Name            string
+   Status          Status
+   RetryAt         *time.Time
+   CanceledAt      *time.Time
+   FinishedAt      time.Time
+   ErrorAt         *time.Time
+   LastErrorString *time.Time
 }

何かしらの状態遷移のテストコードが増えてくる。例えばテスト用のstatus遷移。retryするときのretry間隔の計算などの処理が存在しうる。テストコードの中では以下のように普通にstructを手動で作成してfixtureとして使っているかもしれない(そうだということにする)。

package foo

import (
    "testing"
)

func TestStateTransition(t *testing.T){
    t.Run("testxxxx", t.Run(t *testing.T){
        job := &Job{Status: Canceled}
        Revive(job)
        if job.Status != Status.Waiting {
            t.Errorf("after revived, status should be waiting, but %s", job.Status)
        }
    }
    t.Run("testxxxx2", t.Run(t *testing.T){
      // ...
    }
    t.Run("testxxxx3", t.Run(t *testing.T){
      // ...
    }
    t.Run("testxxxx4", t.Run(t *testing.T){
      // ...
    }
    ...
    // table driven testになっているかもしれないけれど
}

普通にテストがあって良い感じ。

少し変わった挙動を持つものが現れる

少し変わった挙動を持つものが現れる。例えば、しばらくの間だけ処理をスキップしてほしいというようなもの。(Status増やすというような話とは少しだけ違うようなことが必要になった)似たような振る舞いをする部分があるものの完全に同じものではないのでstructを分けたい(Alternativeというstructとする)。元のSkipをしないものと比べてみると状態遷移自体は同じだったので状態に関わる部分に関してはStateという構造体に分けると言うようなことをしたとする。

// State :
type State struct {
    Status          Status
    CanceledAt      *time.Time
    FinishedAt      time.Time
    ErrorAt         *time.Time
    LastErrorString *time.Time
}

// Job :
type Job struct {
    Name string
    State
}

// Alternative :
type Alternative struct {
    Name      string
    Condition string
    State
    Skipped bool
}

ここからが本題。この埋め込みに変えた時に過去に書いたテストのコードも直す必要が出てくる。

テストコードの修正が必要になる。つらい。

テストコードの修正が必要になる。どういう修正が必要かと言うと埋め込みの対応。例えば以下のようにCanceledというStatusで作成していたfixtureを変える必要がある。

job := &Job{Status: Canceled}

Status部分は埋め込みに変えたので直す必要がある。以下のように。

job := &Job{State: {Status: Canceled}}

この種の変更を全てのfixtureへ行う必要がある。つらい。

factory的な関数が用意してあったなら

factory的な関数を用意してあったなら。そのような修正は不要。Factory関数というのは以下のようなもの。(可変長引数で更新用の関数を取るようなちょっとしfunctional optionっぽいインターフェイスにしておくとより便利)

// NewJob :
func NewJob(fs ...func(*Job)) *Job {
    job := &Job{}
    for _, f := range fs {
        f(job)
    }
    return job
}

何が良いのかと言うと。埋め込まれた型の生成はzero valueによる生成され。値への属性アクセスは自動的に埋め込まれた値に移譲される点。このおかげで、factory的な関数を経由した値の生成部分はコードを修正する必要がない。例えば以下の様な形になっているとしたら。このコードは埋め込みに変えた場合にも動く。

job := NewJob(func(job *Job) {
    job.Status = Canceled
})

これは、実質以下の様に初期化された値を

job := &Job{State: State{}}

以下の様に触っていることに他ならないので。

job.State.Status = Waiting

そんなわけで、structの生成にfactory的な関数を用意しておくと埋め込みに変えたときに特に嬉しい。