この記事はスタンバイ Advent Calendar 2024の18日目の記事です。
tl;dr
- IF の定義は呼び出し側で定義する
- IF の実装側は IF ではなく実装をそのまま返す
はじめに
Go言語 100Tips ありがちなミスを把握し、実装を最適化する という書籍を読んで Go っぽい性質の違いを見つけたのでまとめてみます。
私は今まで Java での開発が多く、設計方法などを調べると DB などを操作する実装の際は IF を定義して実装と切り離しましょうといった思想をよく見かけていました。
Go での開発案件を進めるために冒頭で出した Go言語100Tips を読み進めてた時に 生産者側のIF
というセクションで IFは暗黙的に満たされるもの
という性質が取り上げられていました。
今回は集計データを作成して表示用に加工するというケースを想定してサンプルを見ながら考えていきます。
まずはよくないとされる生産者側にIFを定義するパターンです。
パッケージ構成はこんな感じ
- main パッケージ
- mainメソッドで順に処理を呼び出す
- 集計データを作成
- 集計データの加工と表示
- mainメソッドで順に処理を呼び出す
- repository パッケージ
- 集計データの書き込み、読み込みを実装する
- csv 実装と rdb 実装に対応すると仮定する
- total パッケージ
- 集計データを作成して保存します
- summary パッケージ
- 保存された集計データ取得、加工して表示する
実装はダミーですが役割としてはこんなイメージですね
コードを見ていきましょう
生産者側の IF と 消費者側の IF
func main() {
repo := repository.NewCsvTotal("total_file.csv")
// 集計情報を作成
total.Total(repo)
// 集計情報を表示
summary.Summary(repo)
}
type TotalReadWriter interface {
ReadRecord() string
WriteRecord(lineData string)
}
type CsvTotal struct {
FileName string
}
func NewCsvTotal(fileName string) CsvTotal {
return CsvTotal{
FileName: fileName,
}
}
func (t CsvTotal) ReadRecord() string {
// ファイルから読み込みする
return "record_data"
}
func (t CsvTotal) WriteRecord(lineData string) {
// ファイルに書き込み
fmt.Println("write " + lineData + " to " + t.FileName)
}
func Total(t repository.TotalReadWriter) {
fmt.Println("=== build total data. ===")
// 集計データを作成する
t.WriteRecord("builded total data")
}
func Summary(t repository.TotalReadWriter) {
// 集計データを加工して表示する
record := t.ReadRecord()
fmt.Println(record)
}
ここまでが生産者側で IF を定義する例です。
特別不具合はまだ感じませんが依存関係逆転などの思想と比較すると違和感があるかもしれません。
次に消費者側(呼び出し側)で IF を定義してみましょう。
func main() {
repo := repository.NewCsvTotal("total_file.csv")
// 集計情報を作成
total.Total(repo)
// 集計情報を表示
summary.Summary(repo)
}
type CsvTotal struct {
FileName string
}
func NewCsvTotal(fileName string) CsvTotal {
return CsvTotal{
FileName: fileName,
}
}
func (t CsvTotal) ReadRecord() string {
// ファイルから読み込みする
return "record_data"
}
func (t CsvTotal) WriteRecord(lineData string) {
// ファイルに書き込み
fmt.Println("write " + lineData + " to " + t.FileName)
}
type TotalWriter interface {
WriteRecord(lineData string)
}
func Total(t TotalWriter) {
fmt.Println("=== build total data. ===")
// 集計データを作成する
t.WriteRecord("builded total data")
}
type TotalReader interface {
ReadRecord() string
}
func Summary(t TotalReader) {
// 集計データを加工して表示する
record := t.ReadRecord()
fmt.Println(record)
}
IFは暗黙的に満たされる性質を利用して消費者側にIFを定義することによって、実装は生産者側の1箇所に集約しつつ、消費者側では必要な分だけIFを定義することができます。
ただ、だからと言って愚直に実装を進めると生産者側が fat になりそうなのできちんと責務を分離して定義する必要がありそうですね。
IFをリターンする
一般的に Java 思考の場合 IF を使って実装する場合は、実装クラスではなく IF を扱うとこで実装を切り離して柔軟にロジックを定義することができます。しかし、先ほども見た通り Go では消費者側はそれぞれの都合で IF を定義して呼び出しをするので、生産者側で IF を作って返してしまうと実装の際にいくつかの不都合が生まれてしまいます。その例を見てみましょう。
func main() {
// 集計情報を作成
writer := repository.NewCsvTotalWriter("total_file.csv")
total.Total(writer)
// 集計情報を表示
reader := repository.NewCsvTotalReader("total_file.csv")
summary.Summary(reader)
}
type CsvTotal struct {
FileName string
}
func NewCsvTotalReader(fileName string) summary.TotalReader {
return CsvTotal{
FileName: fileName,
}
}
func NewCsvTotalWriter(fileName string) total.TotalWriter {
return CsvTotal{
FileName: fileName,
}
}
func (t CsvTotal) ReadRecord() string {
// ファイルから読み込みする
return "record_data"
}
func (t CsvTotal) WriteRecord(lineData string) {
// ファイルに書き込み
fmt.Println("write " + lineData + " to " + t.FileName)
}
type TotalWriter interface {
WriteRecord(lineData string)
}
func Total(t TotalWriter) {
fmt.Println("=== build total data. ===")
// 集計データを作成する
t.WriteRecord("builded total data")
}
type TotalReader interface {
ReadRecord() string
}
func Summary(t TotalReader) {
// 集計データを加工して表示する
record := t.ReadRecord()
fmt.Println(record)
}
最悪ですね・・・ reader と writer のために同じ内容の定義が発生しており、無理やり対応しているのがわかります。これによりパッケージ構成に工夫が必要になったりと本質的でない部分で頭を悩ませる必要が出てしままいます。また、パッケージ間での import が増えていくと循環参照が発生してコードそのものが動かなくなる可能性も増えてしまいます。
このように IF の立ち位置が Java と Go では異なるもののため、同じように扱うことができない部分もあるわけですね。
これから Go での開発をどんどん進めていくので Go らしい書き方や構成を身に付けたいと思います!
明日は @taca10 さんで toolchainの挙動について学んだ です!お楽しみに!