Go 言語における Functional Option Pattern をラーメン屋で理解する

この記事は個人ブログと同じ内容です

www.ritolab.com


Functional Option Pattern は、Go 言語でよく使用される設計パターンの 1 つです。このパターンを使用することで、柔軟で拡張性の高い API 設計が可能になります。この記事では、Functional Option Pattern の基本的な考え方と実装方法について解説します。

Functional Option Pattern

Functional Option パターンは、関数を使ってオプションの設定を定義し、適用するデザインパターンです。

長いパラメータリスト(その多くがオプション)をコンストラクタや関数に渡す代わりに、設定用の関数(オプション)をリストとして渡します。これにより、デフォルトの設定や挙動を柔軟に変更できます。

Functional Option Pattern の主な利点

  1. オプショナルな設定を柔軟に扱える
    • 必要なオプションのみを指定でき、不要な設定は省略できる
    • オプションの順序も自由に変更できる
    • オプション間の依存関係も明確に表現できる(例:特定の設定が有効な場合のみ必要となる追加設定など)
  2. デフォルト値を自然に設定できる
    • 新しい設定項目を追加する際に、デフォルト値を設定できる
    • デフォルト値の管理が一箇所(コンストラクタ)に集中するため、保守が容易
    • デフォルト値の変更も、既存コードに影響を与えずに行える
  3. コードの可読性が高い
    • パラメータリストが長くなることを防ぎ、コードの見通しを良くする
    • 各オプションの目的が関数名から明確に分かる
    • IDE補完が効きやすく、どのようなオプションが利用可能か分かりやすい
  4. 後方互換性を保ちやすい
    • 新しいオプションを追加しても既存のコードに影響を与えない
    • 既存のオプションの動作を変更する際も、新しいオプション関数を追加することで対応可能
    • 非推奨になったオプションも、段階的に廃止することが容易

簡単な実装例

例えば、サーバーの設定には色々な設定項目がありますが、これを Functional Option Pattern で作成してみます。

まずはサーバーの構造体

package main

import "fmt"

// サーバーの設定を保持する構造体
type Server struct {
    Host string
    Port int
    TLS  bool
}

次に、それぞれのオプション設定を関数として定義します。それぞれのオプション関数は、シンプルにサーバー構造体が保持している値を設定しているだけです。

// ホスト名を設定するオプション
func WithHost(host string) func(*Server) {
    return func(s *Server) {
        s.Host = host
    }
}

// ポート番号を設定するオプション
func WithPort(port int) func(*Server) {
    return func(s *Server) {
        s.Port = port
    }
}

// TLS を有効化するオプション
func WithTLS() func(*Server) {
    return func(s *Server) {
        s.TLS = true
    }
}

最後に、サーバー構造体のコンストラクタ関数を定義します。この中で、オプションを適用するようにしていきます。(必ずしもコンストラクタ関数で適用しないといけないということではありません)

func NewServer(options ...func(*Server)) *Server { // [1] 引数でオプション関数のスライスが渡ってくる
    server := &Server{
        Host: "localhost",
        Port: 8080,
        TLS:  false,
    }
    
    // [2] オプション関数のスライスをループで回して、オプションを適用
    for _, optionFunc := range options {
        optionFunc(server)
    }
    
    return server
}

[2] の部分が Functional Option Pattern のポイントです。オプション関数のスライスをループで回して、サーバー構造体に対してオプション関数を実行し、値を設定しています。

オプションがあるだけ関数を実行し設定しますし、オプションがなければ関数は実行されないのでオプションのままです。この設計が柔軟性を生み出しています。

また、各オプションを関数で定義するため、それぞれの意図もわかりやすく、可読性も高いです。

実際に適用している箇所も、ループで回して設定しているだけですから、記述量も最小限ですし、オプションが増えても既存のコードに影響を与えずに済みます。後方互換性も保てるというわけです。

さて、これでオプション適用の実装ができたので、実際にこれを使用してみます。

func main() {
    // デフォルト設定でサーバーを作成
    defaultServer := NewServer()
    fmt.Printf("Default Server: %+v\n", defaultServer)

    // カスタム設定でサーバーを作成
    customServer := NewServer(
        WithHost("example.com"), // 1 つ目のオプション
        WithPort(9090),          // 2 つ目のオプション
        WithTLS(),               // 3 つ目のオプション
    )
    fmt.Printf("Custom Server: %+v\n", customServer)
}

上記のコードを実行すると、以下のような出力が得られます

Default Server: {Host:localhost Port:8080 TLS:false}
Custom Server: {Host:example.com Port:9090 TLS:true}

Functional Option パターンは上記のようにして実装できます。

ラーメン屋さんで理解する Functional Option Pattern

Web サーバーなんて滅多に立てるものでもないですから、もっと具体的な例も見てみましょう。

私たちは日頃、ラーメン屋さんにとてもお世話になっているわけですが、行きつけの「ジローラ系モラーメン」は、あの独特なオプションで有名です。

私たちの注文をスタッフさんが厨房に届けるまでを Functional Option パターンで実装してみます。

ソースコードの全容は以下です。前述したこと以上のものはありませんので、読んでみてください。

package main

import (
  "fmt"
  "strings"
)

// RamenOrder ラーメンの注文を表す構造体
type RamenOrder struct {
  garlic     Amount // にんにく
  vegetables Amount // 野菜
  oilLevel   Amount // 油の量
}

// RamenOption ラーメンのオプションを設定する関数の型
type RamenOption func(*RamenOrder)

// デフォルトのラーメン設定
func defaultRamenSetting() *RamenOrder {
  return &RamenOrder{
    garlic:     Regular,
    vegetables: Regular,
    oilLevel:   Regular,
  }
}

// 量に関する Enum を表現(ジローラモ系は特殊なため)
type Amount int

const (
  Light Amount = iota
  Regular
  Extra
  Ultimate
)

// String メソッドを実装して、各値を文字列として表現できるようにする
func (a Amount) String() string {
  switch a {
  case Light:
    return "少なめ"
  case Regular:
    return "普通"
  case Extra:
    return "マシ"
  case Ultimate:
    return "マシマシ"
  default:
    return "多分マシマシ" // スタッフの気分による
  }
}

// -- ここから Functional Option Pattern -- //

// WithGarlic にんにく量を指定するオプション
func WithGarlic(a Amount) RamenOption {
  return func(r *RamenOrder) {
    r.garlic = a
  }
}

// WithVegetables 野菜の量を指定するオプション
func WithVegetables(a Amount) RamenOption {
  return func(r *RamenOrder) {
    r.vegetables = a
  }
}

// WithOilLevel 油の量を設定するオプション
func WithOilLevel(a Amount) RamenOption {
  return func(r *RamenOrder) {
    r.oilLevel = a
  }
}

// NewRamenOrder 新しいラーメンの注文を作成する
func NewRamenOrder(opts ...RamenOption) *RamenOrder {
  r := defaultRamenSetting()
  
  // ここでオプションを適用
  for _, opt := range opts {
    opt(r)
  }
  
  return r
}

// Call Ramenのオプション情報を文字列として返す(スタッフさんが厨房にオーダーを通す)
func (r *RamenOrder) Call() string {
  options := []string{}

  if r.garlic != Regular {
    options = append(options, fmt.Sprintf("にんにく%s", r.garlic))
  }

  if r.vegetables != Regular {
    options = append(options, fmt.Sprintf("野菜%s", r.vegetables))
  }

  if r.oilLevel != Regular {
    options = append(options, fmt.Sprintf("油%s", r.oilLevel))
  }

  optionStr := ""
  if len(options) > 0 {
    optionStr = fmt.Sprintf("、%sで!!", strings.Join(options, "、"))
  }

  return fmt.Sprintf("注文入りました!ラーメン一丁%s", optionStr)
}

func main() {
  // はじめて
  ramenOrder1 := NewRamenOrder()
  fmt.Println(ramenOrder1.Call())

  // 健康志向
  ramenOrder2 := NewRamenOrder(
    WithVegetables(Ultimate),
  )
  fmt.Println(ramenOrder2.Call())

  // 深夜の背徳感
  ramenOrder3 := NewRamenOrder(
    WithGarlic(Ultimate),
    WithOilLevel(Extra),
  )
  fmt.Println(ramenOrder3.Call())

  // フードファイター
  ramenOrder4 := NewRamenOrder(
    WithGarlic(Ultimate),
    WithVegetables(Ultimate),
    WithOilLevel(Ultimate),
  )
  fmt.Println(ramenOrder4.Call())
}

上記実装の実行結果は以下になります。

注文入りました!ラーメン一丁
注文入りました!ラーメン一丁、野菜マシマシで!!
注文入りました!ラーメン一丁、にんにくマシマシ、油マシで!!
注文入りました!ラーメン一丁、にんにくマシマシ、野菜マシマシ、油マシマシで!!

ちなみに、全てマシマシのときは本当は「全マシマシ」とかいうらしいです。

まとめ

Functional Option パターンは、コードの柔軟性と可読性を向上させるだけでなく、拡張性にも優れています。特に、構造体や関数に対して多くのオプションを扱う必要がある場合に、このパターンを採用すると効果的です。

  • オプション設定が直感的に理解できる。
  • デフォルト値の管理が簡単になる。
  • 新しい設定項目を追加する際のコード変更が最小限で済む。

このパターンを活用して、よりメンテナンス性の高い Go コードを目指しましょう。