Goのファーストクラス関数：適切な活用方法

[![表紙](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3967563/96903c46-a109-4e6a-995b-de76a485c9fb.png)](https://leapcell.io/?lc_t=q_gofirstcitizen)

まず、Ward Cunningham による「ファーストクラスシチズン（一等公民）」の解釈を見てみましょう。

> もしあるプログラミング言語が、ある種の言語要素の生成と使用に制限を設けず、その構文要素を値（value）と同じように扱うことができるならば、私たちはその構文要素をこのプログラミング言語の「ファーストクラスシチズン」と呼ぶことができます。

簡単に言えば、Go は関数を変数に代入することができます。

関数は、関数や変数の型、または戻り値として、引数として渡すことができます。

## 引数として

kube-proxy では makeEndpointFunc が定義されており、ipvs、nftables、iptables それぞれに対応したメソッドがあります。

```go
type makeEndpointFunc func(info *BaseEndpointInfo, svcPortName *ServicePortName) Endpoint
```

実装は異なりますが、関数型で統一することにより、異なるハードウェアのサポート下でも同じ Endpoint 情報をインスタンス化できます。アプリケーション側は Endpoint の具体的な実装を気にする必要はなく、必要なノード情報を取得できれば上位のロジックが正常に動作します。Endpoint の具体的な実装に依存しません。

```go
func NewEndpointsChangeTracker(hostname string, makeEndpointInfo makeEndpointFunc, ipFamily v1.IPFamily, recorder events.EventRecorder, processEndpointsMapChange processEndpointsMapChangeFunc) *EndpointsChangeTracker {
 return &EndpointsChangeTracker{
  endpointSliceCache:        NewEndpointSliceCache(hostname, ipFamily, recorder, makeEndpointInfo),
 }
}
```

NewEndpointsChangeTracker は、渡された makeEndpointInfo を直接使用して Cache を初期化しています。

## クロージャによる状態を持つ関数の実現

通常、関数呼び出しが終わると対応するスタックメモリは解放されますが、クロージャを持つ関数の場合は、クロージャが呼び出し終わるまで対応するスタックメモリは解放されません。

専門的な言い方をすれば、関数呼び出しが戻った後も、リソースが解放されていないスタック領域が存在するということです。

性能上のオーバーヘッドはありますが、コーディングの利便性をもたらすことで、適切な場面で活用すればコードの柔軟性を高めることができます。

クロージャを使ってパラメータの受け渡しや新しい関数の生成を実現できるため、状態を持った関数を実装できます。

```go
func NewDeploymentController(...) (*DeploymentController, error) {
  logger := klog.FromContext(ctx)

 dInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
  AddFunc: func(obj interface{}) {
   dc.addDeployment(logger, obj)
  },
  UpdateFunc: func(oldObj, newObj interface{}) {
   dc.updateDeployment(logger, oldObj, newObj)
  },
  DeleteFunc: func(obj interface{}) {
   dc.deleteDeployment(logger, obj)
  },
 })
}
```

ResourceEventHandlerFuncs を呼び出す際、logger パラメータを渡す必要がなく、クロージャによって logger を直接関数に組み込むことができます。logger パラメータの定義も不要になります。

## 柔軟な可変長引数

k8s の SharedInformerFactory 構築メソッドの例を見てみましょう。

```go
type SharedInformerOption func(*sharedInformerFactory) *sharedInformerFactory
```

SharedInformerOption は関数型であり、sharedInformerFactory を受け取って値を設定し、返り値も sharedInformerFactory です。

```go
func WithNamespace(namespace string) SharedInformerOption {
 return func(factory *sharedInformerFactory) *sharedInformerFactory {
  factory.namespace = namespace
  return factory
 }
}

type sharedInformerFactory struct {
 //...
 namespace        string
 //...
}
```

WithNamespace はクロージャを利用して factory パラメータの設定を実現し、WithNamespace 自体も関数型を返します。

```go
func NewSharedInformerFactoryWithOptions(
 client kubernetes.Interface,
 defaultResync time.Duration,
 options ...SharedInformerOption,
) SharedInformerFactory {
 factory := &sharedInformerFactory{}

 // ここで全ての可変長引数を適用
 for _, opt := range options {
  factory = opt(factory)
 }

 return factory
}
```

この例の options は可変長引数です。Go が同じ型の可変長引数をサポートすることを利用していますが、実際のビジネスでは異なる型をセットしたい場合も多いため、クロージャ関数で引数を柔軟に適用することができます。

## ファンクター（函子）の応用

ファンクターは、コンテナコレクションの要素に対して一括して同型処理を行うのに非常に適しており、毎回ループ処理を記述するよりも優雅で簡潔なコードにできます。ファンクターはインターフェースであり、コンテナ要素変換関数を受け取る関数を定義し、返り値はファンクターインターフェースです。

ファンクターの実装は構造体であり、構造体はコンテナプロパティを持ち、インターフェース関数を実装します。ロジックはコンテナ要素をループし、変換関数を呼び出し、その結果を新しいコンテナに append し、最後に新しいコンテナから新しい構造体を生成して返します。

これは、各データを個別に処理しなければならないケースや、プラグインのように処理ロジックを自由に切り替えたいケースに適しています。

以下の例を見てみましょう。

```go
type User struct {
 ID   int
 Name string
}

type Functor[T any] []T

func (f Functor[T]) Map(fn func(T) T) Functor[T] {
 var result Functor[T]
 for _, v := range f {
  result = append(result, fn(v))
 }
 return result
}
```

Functor でファンクターのジェネリクスを定義し、Map で各要素を処理します。

```go
func main() {
 users := []User{
  {ID: 1, Name: "Alice"},
  {ID: 2, Name: "Bob"},
  {ID: 3, Name: "Charlie"},
 }

 upperUserName := func(u User) User {
  u.Name = strings.ToUpper(u.Name)
  return u
 }

 us := Functor[User](users)
 us = us.Map(upperUserName)

 // 変換後のユーザーデータを出力
 for _, u := range us {
  fmt.Printf("ID: %d, Name: %s\n", u.ID, u.Name)
 }
}
```

Go 1.18 からジェネリクスがサポートされたことで、ファンクターの活用範囲が広がりました。以前は型ごとに毎回実装する必要があり、コードの汎用性が低かったのです。

ファンクターはシンタックスシュガー（糖衣構文）に近いものに見えるかもしれません。実際のロジック作成時にファンクターがコード抽象の助けになるかどうかは状況によりますが、ここでは主に活用のテクニックを紹介しています。実際に使うかどうかは自分の状況に応じて決めてください。

## 良い関数を書くには

これだけ多くのテクニックも、最終的な目的はシステム設計をコードに落とし込み、コードをより理解しやすく保守しやすくすることにあります。

では、実践の中でどうすれば本当に良い関数を書けるでしょうか？

### 関数の引数を減らす

ひとつの関数に引数が多すぎる場合、それらを構造体でまとめて集約できないか検討できます。

ユーザー作成の例を見てみましょう。

```go
func createUser(name string, age int, email string, address string, phoneNumber string) error{
    // ...
}
```

ここでは全ての引数がユーザー情報に該当するため、User 構造体にまとめることができます。

```go
// 構造体でパラメータをまとめる
type User struct {
    Name        string
    Age         int
    Email       string
    Address     string
    PhoneNumber string
}

func createUser(user User) error {
 //...
}
```

このようにすることで、引数により多くの意味を持たせることができ、構造体として集約することでコードを読む際にもより多くのコンテキスト情報を得られます。

また、可変長引数を利用すれば、修正可能でありつつデフォルト値も提供でき、呼び出し側が過度なパラメータ指定を考えずに済みます。設定値が連動して変化する場合は、二つ書かずに一方から他方を導出すれば十分です。

### 内部変数を減らす

メソッド内部に変数が多すぎる場合、そのメソッドが過剰な責務を負っていることを意味し、分割が必要です。

**スイスアーミーナイフのような関数は良くありません。**呼び出し側に過剰な負担を強い、変更時もすべての利用箇所を確認して副作用がないか気にしなければなりません。

実際にどう評価するかというと、ユニットテストを書くことでその関数の責務が多すぎるかを確認できます。

関数をテストしてみて、ユニットテストが非常に書きづらい場合は、その関数の責務が多すぎるか、意味が不明瞭なことを疑いましょう。

```go
func CreateOrder(order *Order) error {
 // 注文の検証
 if len(order.Items) == 0 {
  return errors.New("order must have at least one item")
 }

 // 合計金額を計算し、在庫を更新
 totalPrice := 0.0
 for _, item := range order.Items {
  price, err := GetProductPrice(item.ProductID)
  if err != nil {
   return err
  }
  totalPrice += price * float64(item.Quantity)

  // 在庫の更新
  if err := UpdateInventory(item.ProductID, item.Quantity); err != nil {
   return err
  }
 }

 return nil
}
```

このメソッドでは、ユニットテストを書く際に検証すべきことが非常に多いことが分かります。

注文の妥当性、合計金額計算の正確さ、在庫更新の成否など、一つでも変更が入るとテストがさらに複雑になります。

そこで、検証、在庫、合計金額計算を分割できます。

```go
func ProcessOrder(order *Order) error {
 // 検証
 if err := ValidateOrder(order); err != nil {
  return err
 }

 // 合計金額計算
 totalPrice, err := CalculateTotalPrice(order)
 if err != nil {
  return err
 }

 // 在庫処理
 if err := ProcessInventory(order); err != nil {
  return err
 }

 return nil
}
```

このようにすると、ProcessOrder 自体をテストする必要がなくなり、統合テストで検証できます。ProcessOrder は「のり付け」的な役割を果たすためです。

個別に注文検証、合計金額計算、在庫処理をテストする方が簡単になります。

### 関数の長さに注意する

メソッドの長さはどれくらいが適切か、厳密な基準はありませんが、長さを過度に追求しすぎると逆に副作用が出ることもあります。

重要なのは関数のサイクロマティック複雑度を抑え、複雑度が高くなりすぎて読む時に必要なコンテキスト情報が多くなりすぎるのを防ぐことです。そのためには、できるだけ早く return し、関数の読みやすさを保つことが大切です。

ユーザー登録の例を見てみましょう。

```go
func RegisterUser(username, password, email string) error {
 if username == "" {
  return errors.New("username cannot be empty")
 } else {
  if len(username) < 3 {
   return errors.New("username must be at least 3 characters long")
  } else {
   if password == "" {
    return errors.New("password cannot be empty")
   } else {
    if len(password) < 6 {
     return errors.New("password must be at least 6 characters long")
    } else {
     if email == "" {
      return errors.New("email cannot be empty")
     } else {
      if !isValidEmail(email) {
       return errors.New("invalid email format")
      } else {
       // ユーザー登録処理
       return nil
      }
     }
    }
   }
  }
 }
}
```

エラーを早期 return にすると、以下のように書けます。

```go
func RegisterUser(username, password, email string) error {
 if username == "" {
  return errors.New("username cannot be empty")
 }
 if len(username) < 3 {
  return errors.New("username must be at least 3 characters long")
 }
 if password == "" {
  return errors.New("password cannot be empty")
 }
 if len(password) < 6 {
  return errors.New("password must be at least 6 characters long")
 }
 if email == "" {
  return errors.New("email cannot be empty")
 }
 if !isValidEmail(email) {
  return errors.New("invalid email format")
 }

 // ユーザー登録処理
 return nil
}
```

### 関数命名に注意する

関数名をどう付けるかは、長期的にレビューし続けるべきポイントです。

たとえば、ユーザーのショッピングカートの合計金額を計算する場合、次のような命名はどうでしょうか。

```go
func CalculateTotalPriceOfCartForUser(userID int, cartID int) float64 {}
```

この命名より、下記のような命名の方がはるかに分かりやすいです。

```go
func GetCartTotal(userID, cartID int) float64 {}
```

小さなメソッドが増えてくると、命名のばらつきによって同じ機能のメソッドを複数人が重複して作ってしまうことがあります。このような状況は、関数命名規則を設けることである程度回避できます。

直感的に対応する機能のメソッドをすぐに見つけられるなら、わざわざ新たに書こうとは思わないはずです。答えは明らかに「No」ですね。

### 関数の抽象レイヤー

関数内のコードがすべて同じ抽象レイヤーにあるかどうかも、良い関数かどうかを測る基準のひとつです。

同じ抽象レイヤーであれば、その関数が何をしているのかをすばやく把握できます。

たとえば、Controller はパラメータチェック、Service はビジネスロジック、Dao はデータベース操作という基本的な MVC の三層分離のイメージです。もし Controller でデータベースアクセスをしてしまうと、コードの抽象レベルが飛び越えてしまい、理解の難易度も上がります。

### 関数は「重複排除」のためだけのものではない

関数は確かに重複コードを排除することもできますが、それが関数の本質ではありません。関数の本当の価値は「抽象化を生み出すこと」にあります。

もし「重複コードの排除」だけを目的に関数を抽象化すると、最終的にはコードがごちゃごちゃになり、再利用の必要がない時にはその関数が何をしているのかさえ分からなくなることがあります。

私自身も、関数の抽象化を最初は「重複があるかどうか」で判断していました。しかし要件が増えるにつれ、もともと共通化していたコードがどんどん個別対応になり、最終的には全く違う方向へ進化してしまいました。その時、単なる「重複排除」のために作った関数が分かりづらくなっていることに気づきました。

良い関数を書くためには、「良い抽象」を作ることが重要です。たとえ今は再利用しなくても、ビジネスが拡大していくにつれて抽象化されたロジックは徐々に再利用されていきます。

レイヤーが上がるにつれて細部は少なくなり、私たちが解決したい本質的な問題により近づきます。

私たちが業務課題を議論する時も、抽象化されたオブジェクトを用いることが多いです。その業務課題をどうやって「保守性の高いコード」に翻訳するか、それこそがエンジニアとして努力すべき方向なのです。

## まとめ

もし極限まで性能を求める場合、コードの可読性を多少犠牲にしなければならないこともありますが、業務開発の現場ではそのようなケースは稀なので、ここではあまり論じません。

ここまでの内容をまとめると：

- Go の関数はファーストクラスシチズンであり、変数に代入したり、引数として渡したり、戻り値として返すことができます。kube-proxy の makeEndpointFunc 定義では、統一された関数型によって異なるハードウェアで同じインターフェースを実現しています。
- クロージャによって関数は状態を保持でき、外部変数を内部関数にキャプチャでき、コードの柔軟性が向上します。NewDeploymentController ではクロージャで logger を直接参照し、余計なパラメータの受け渡しを省いています。SharedInformerOption のようなクロージャ関数を使うことで、より柔軟な可変長引数も実現できます。

最後に「良い関数を書くには？」についても議論しました：

- 構造体でまとめたり関数分割することで、関数の引数や内部変数を減らす
- 関数の長さやサイクロマティック複雑度を制御し、早期 return で読みやすさを高める
- 関数命名を正確かつシンプルにして、重複の再発を防ぐ
- 関数内のコードを同じ抽象レイヤーに揃え、素早く理解できるようにする
- 関数の価値は抽象化にあり、単なる重複排除ではない

---

### [私たちはLeapcell、Goプロジェクトのホスティングの最適解です。](https://leapcell.io/?lc_t=q_gofirstcitizen)

[![Leapcell](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3967563/c3d4811c-d0e0-4ba8-8466-5d37478d0d29.png)](https://leapcell.io/?lc_t=q_gofirstcitizen)

[Leapcell](https://leapcell.io/?lc_t=q_gofirstcitizen)は、Webホスティング、非同期タスク、Redis向けの次世代サーバーレスプラットフォームです：

**複数言語サポート**

- Node.js、Python、Go、Rustで開発できます。

**無制限のプロジェクトデプロイ**

- 使用量に応じて料金を支払い、リクエストがなければ料金は発生しません。

**比類のないコスト効率**

- 使用量に応じた支払い、アイドル時間は課金されません。
- 例: $25で6.94Mリクエスト、平均応答時間60ms。

**洗練された開発者体験**

- 直感的なUIで簡単に設定できます。
- 完全自動化されたCI/CDパイプラインとGitOps統合。
- 実行可能なインサイトのためのリアルタイムのメトリクスとログ。

**簡単なスケーラビリティと高パフォーマンス**

- 高い同時実行性を容易に処理するためのオートスケーリング。
- ゼロ運用オーバーヘッド — 構築に集中できます。

[ドキュメントで詳細を確認！](https://docs.leapcell.io/)

[![Try Leapcell](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3967563/7731d1fb-8ff0-4bcb-8248-98a8ebcc9626.png)](https://leapcell.io/?lc_t=q_gofirstcitizen)

Xでフォローする：[@LeapcellHQ](https://x.com/LeapcellHQ)

---

[ブログでこの記事を読む](https://leapcell.io/blog/how-to-write-better-go-functions)