🖥️

Swift のようなシンプルな書き方で Go の既存の interface を extension する

2022/07/17に公開約9,800字

Goの既存のインターフェースにメソッドを追加したくなったら

※ 完全な Go言語のコードはこちら:

https://github.com/Takakiriy/Trials/tree/master/go_centos7/example/inheritance

既存のインターフェース(クラス)に対してメソッドを追加したいと思ったことがあると思います。Swift言語ではこの場合、既存のクラスにメソッドなどを追加する extension を使いますが、Go言語ではどのように書けばよいかを説明します。

たとえば、ライブラリに Player クラスがあり、次のように使っていたとします。下記は Swift のコードです。

  var player = Player()  // player オブジェクトを生成します
  player.printLevel()    // player オブジェクトの printLevel メソッドを呼び出します

使っているうちに、printClass メソッドもあると便利だろうということに気づいたとします。

  var player = Player()
  player.printLevel()
  player.printClass()  // "professional" または "amateur" を表示します(追加)

この場合、printClass メソッドが使えるように拡張するには、Swift言語では class と同じような形で extension と printClass メソッドの定義を書きます。

  extension Player {

      func printClass() {
          if level >= 20 {
              print("professional")
          } else {
              print("amateur")
          }
      }
  }

ちなみに、既存の Player クラスの定義は以下のように書かれています。特別なことはしていません。普通に書いたクラスに対して extension が使えます。

  class Player {
      var level = 1

      func printLevel() {
          print(level)
      }
  }

Go言語は extension をサポートしていませんが、それに近い書き方をすることができます。

  player := NewPlayer()
  player.printLevel()
  PlayerEx{player}.printClass()  // "professional" または "amateur" を表示します(追加)
  PlayerEx{player}.printLevel()  // 既存のメソッドも呼べます

サブクラス PlayerEx があると extension(拡張)ではないと思われるでしょうがそこはご容赦ください。その代わりに NewPlayerEx のようなサブクラスのオブジェクトを生成する関数を呼び出す必要がありません。既存のメソッドから返されたオブジェクトに対して拡張することができます。厳密にはPlayerEx{ } がサブクラスのオブジェクトを生成する関数に相当していると言えなくもないですがそのデメリットは小さいです。

ちなみに、以下のようなクラスに属さない関数を呼び出しても同じ処理をすることはできますが、オブジェクトや概念の構成を理解する上で混乱する要素になってしまいます。クラスに属させると、Visual Studio Code などで PlayerEx クラスの定義にジャンプできるようになり、構成を理解する上で大きな助けになります。

  PlayerPrintClass(player)  // あまり良くない

(メモ)Go言語での正しい用語は、クラス→構造体+レシーバー、メソッド→レシーバーです。

既存のインターフェースを埋め込みで拡張する

インターフェースを拡張する書き方を紹介します。

既存の cron.Schedule インターフェースに

  • ExtraMethod レシーバー(メソッド)

を追加するコードは、以下のようになります。

  // ScheduleEx はインターフェースを埋め込んでいます
  type ScheduleEx struct {
      cron.Schedule
  }

  func (s ScheduleEx) ExtraMethod() { // interface を埋め込んだ struct はポインターを渡さないこと
      fmt.Printf("(ScheduleEx) ExtraMethod\n")
  }

cron.Schedule は interface ですが、struct に埋め込むこと(embedding)ができます。メンバー変数名を書かずに型名だけ書くと埋め込みになります。interface を埋め込んだ struct のレシーバー(メソッド)のオブジェクトの型は、ポインター(*ScheduleEx)ではなく構造体(ScheduleEx)を書いてください。そうしないと呼び出すコードを書くときにエラーになります。

埋め込まずに直接 cron.Schedule のレシーバーを定義しようとすると、インターフェースが別のパッケージで定義されているというエラーになります。

  func (s cron.Schedule) ExtraMethod() {  // エラー
      fmt.Printf("(cron.Schedule) ExtraMethod\n")
  }

追加したメソッドを呼び出すコードは以下のように ScheduleEx でラップしてから(包んでから)呼び出します。

  func ExtensionMain() {
      schedule, _ := cron.Parse("*/5 * * * * *") // cron.Schedule を返す
      ScheduleEx{schedule}.ExtraMethod()  // 追加したメソッド
  }

埋め込んでいるので特に定義を書かなくても cron.Schedule の既存の Next メソッドを ScheduleEx オブジェクトから呼び出すことができます。

  func ExtensionMain() {
      schedule, _ := cron.Parse("*/5 * * * * *") // cron.Schedule を返す
      nextTime := schedule.Next(time.Now())
      fmt.Println(nextTime)

      nextTime = ScheduleEx{schedule}.Next(time.Now()) // schedule.Next と同じ
      fmt.Println(nextTime)

      ScheduleEx{schedule}.ExtraMethod()  // 追加したメソッド
  }

既存の構造体 cron.Schedule 型の変数から、拡張した構造体 ScheduleEx に追加した ExtraMethod レシーバーを直接呼び出すことはできません。

  schedule, _ := cron.Parse("*/5 * * * * *")
  schedule.ExtraMethod()  // エラー

拡張した構造体の型の変数を用意すれば、追加したレシーバーを直接呼び出すことができます。

  schedule_, _ := cron.Parse("*/5 * * * * *") 
  schedule := ScheduleEx{schedule_}

  schedule.ExtraMethod()

既存の構造体へのポインターを埋め込みで拡張する

構造体へのポインターを拡張する書き方を紹介します。

既存の *regexp.Regexp 型に

  • ReplaceToHyphen レシーバー(メソッド)

を追加するコードは、以下のようになります。

  // RegExpEx は構造体へのポインターを埋め込んでいます
  type RegExpEx struct {
      *regexp.Regexp
  }

  func (s RegExpEx) ReplaceToHyphen(source string) string { // ポインターを埋め込んだ struct もポインターを渡さないこと
      return s.ReplaceAllString(source, "--")
  }

*regexp.Regexp はポインターですが、struct に埋め込むこと(embedding)ができます。

埋め込まずに直接 *regexp.Regexp のレシーバーを定義しようとすると、構造体が別のパッケージで定義されているというエラーになります。

  func (s *regexp.Regexp) ReplaceToHyphen(source string) string {  // エラー
      return s.ReplaceAllString(source, "--")
  }

追加したメソッドを呼び出すコードは以下のように RegExpEx でラップしてから(包んでから)呼び出します。

  func ExtensionPointerMain() {
      re := regexp.MustCompile(`[A-Za-z]+`) // *regexp.Regexp を返す
      replaced := RegExpEx{re}.ReplaceToHyphen("123abc456def789")  // 追加したメソッド
  }

埋め込んでいるので特に定義を書かなくても *regexp.Regexp の既存の ReplaceAllString メソッドを RegExpEx オブジェクトから呼び出すことができます。

  func ExtensionPointerMain() {
      re := regexp.MustCompile(`[A-Za-z]+`) // *regexp.Regexp を返す
      replaced = RegExpEx{re}.ReplaceAllString("123abc456def789", "--")
  }

既存の構造体へのポインター *regexp.Regexp 型の変数から、拡張した構造体 RegExpEx に追加した ReplaceToHyphen レシーバーを直接呼び出すことはできません。

  re := regexp.MustCompile(`[A-Za-z]+`)
  re.ReplaceToHyphen()  // エラー

拡張した構造体の型の変数を用意すれば、追加したレシーバーを直接呼び出すことができます。

  re_ := regexp.MustCompile(`[A-Za-z]+`)
  re := RegExpEx{re_}

  re.ReplaceToHyphen()

追加したレシーバーを持つインターフェースを定義する

これはあまり使われませんが、ScheduleEx{schedule_}.ExtraMethod() のような間接的な呼び出しではなくschedule.ExtraMethod() のように直接呼び出せる型であれば、

  • 追加した ExtraMethod レシーバーを持つインターフェースに代入する

こともできるようになります。

  type ScheduleInrerface interface {  // インターフェースの定義
      Next(now time.Time) time.Time  // 既存のレシーバー。cron.Schedule の内容を改めて書く
      ExtraMethod()  // 追加したレシーバー
  }

ただし、interface があるコードは追うことが難しくなるのでどうしても必要になったときだけに限定してください。将来のためにあらかじめ用意しておかなかったとしても修正するコードは少しだけで済みますし、使う側のコードの変更は発生しません。APIは仕様が安定している必要がありますが interface である必要はありません。必要なときは使う側(別のパッケージ)にコールバックするときと後で示す内部的なケースだけです。

既存のインターフェースに含まれる既存の Next レシーバーを呼び出す必要があればそれも改めて書かなければなりません。なぜ既存の Next レシーバーを改めて書かなければならないのかというと、拡張したインターフェースに既存のインターフェースを埋め込もうとしてもエラーになるからです。

  type ScheduleInrerface interface {
      cron.Schedule  // エラー
  }

おそらく既存のインターフェースから継承した新しいインターフェース(通常、アプリケーションが使うインターフェース)を定義できるようにすると、新しいインターフェースの構成要素(レシーバーなど)のほとんどが使われないのにインテリセンスやデバッガーで一覧されてしまい、使い勝手が悪くなるからという思想を言語仕様に入れたのだと思われます。

ちなみに、インターフェースを拡張した構造体は、新しいインターフェースの変数だけでなく、既存のインターフェースの変数に代入することもできます。

  schedule := ScheduleEx{schedule_}
  newInterface := ScheduleInrerface(schedule)
  originalInterface := crom.Schedule(schedule)

メンバー変数を追加する拡張をする

これもあまり使われませんが、メンバー変数を追加して拡張するケースも説明します。

  schedule_, _ := cron.Parse("*/5 * * * * *") 
  schedule := CastToScheduleMemo(schedule_)

  schedule.NextTime_  // 追加したメンバー変数
  schedule.ExtraMethod()  // 追加したレシーバー

既存の cron.Schedule インターフェースに

  • NextTime_ メンバー変数(今回の主役!)
  • NextTime レシーバー
  • ExtraMethod レシーバー

を追加した ScheduleMemo 構造体のコードを以下に示します。

  type ScheduleMemo struct {
      NextTime_ time.Time  // 追加したメンバー変数。メモ化した値
      cron.Schedule
  }

  func CastToScheduleMemo(cron cron.Schedule) *ScheduleMemo {
      s := &ScheduleMemo{
          Schedule: cron,
      }
      s.NextTime_ = s.NextTime()  // 内部で追加したレシーバーを呼び出して追加したメンバー変数に格納
      return s
  }

  func (s ScheduleMemo) NextTime() time.Time {
      return s.Next(time.Now())
  }

  func (s *ScheduleMemo) ExtraMethod() { // 追加したレシーバー。interface を埋め込んでいないのでポインター(*)
      fmt.Println("(ScheduleMemo) ExtraMethod")
  }

多くのアプリケーションが必要とするデータ(拡張で追加する属性)は、ライブラリが用意する膨大なデータの中の一部だけあり、また、ライブラリが用意する値から少し加工した値であることが多いです。そのような値を格納する(メモ化する)メンバー変数を追加すると便利かつ少し高速になります。特に Go言語は、デバッガーでオブジェクトのプロパティの値(getter が返す値)を表示することができないため、メンバー変数を追加しなければ開発効率が悪くなります。

拡張した型と、別の型で共通のメンバー変数

これはよく使われます。既存のライブラリが提供する型とは別に、同様の意味の型を使いたくなったことや、同様の意味の型をアプリケーションで定義したくなったことがあると思います。これに気づいたときは、大きなコード変更が発生するので早いうちに後で示すコードに変更します。

  // cron.Schedule オブジェクトをラップしたオブジェクト
  schedule_, _ := cron.Parse("*/5 * * * * *")  // 次の定刻
  schedule := CastToScheduleFacade(schedule_)
  schedule.NextTime       // アプリケーションが使うメンバー変数
  schedule.ExtraMethod()  // アプリケーションが使うレシーバー

  // ラップする対象がない同様の意味のオブジェクト
  schedule = &ScheduleFacade{NextTime: time.Now()}  // 今すぐ
  schedule.NextTime       // アプリケーションが使うメンバー変数
  schedule.ExtraMethod()  // アプリケーションが使うレシーバー

この場合、

  • 共通のメンバー変数 NextTime(ラップする対象の外に配置する
  • それぞれの型のオブジェクトのメンバー変数(ラップする対象、委譲する対象)
  • 共通のレシーバー ExtraMethod

を持つ ScheduleFacade 構造体を定義します。今まで説明してきたインターフェースの埋め込みは ScheduleFacade 構造体に対して行わず、委譲先の型に対して行います。共通のメンバー変数を置く位置はラップする対象の外に配置します。

  // 新しい構造体の定義
  type ScheduleFacade struct {
      NextTime time.Time      // アプリケーションが使う共通のメンバー変数
      cron     cron.Schedule  // object or nil。ラップする対象、委譲する対象
  }

  // ラップするように型を変換する
  func CastToScheduleFacade(cron cron.Schedule) *ScheduleFacade {
      schedule := CronScheduleEx{cron}

      return &ScheduleFacade{
          NextTime: schedule.NextTime(),
          cron:     cron,
      }
  }

  func (s *ScheduleFacade) ExtraMethod() {
      fmt.Println("(ScheduleFacade) ExtraMethod")
  }

  // CronScheduleEx はインターフェースを埋め込んでいるので拡張することができます
  type CronScheduleEx struct {
      cron.Schedule
  }

  func (s CronScheduleEx) NextTime() time.Time {  // 追加したメソッド
      return s.Next(time.Now())
  }

ScheduleFacade 構造体に cron.Schedule インターフェースを埋め込んでしまうと、どちらの型であるか(cron.Schedule オブジェクトをラップしているかラップしていないか)の判断が難しくなります。また、埋め込まないことで DTO (Data Transfer Object) と DAO (Data Access Object) を分けたときと同様に、注目すべきデータを(アプリケーションが使う共通のメンバー変数に)絞り込めるメリットとアクセス回数が少なくなるメリットが期待できます。

共通の getter/setter を持つインターフェースを定義して、それぞれの型をインターフェースに合わせる方法もありますが、インターフェースを使ったコードは追いにくいですし、メモ化してメンバー変数を追加したほうが前述のとおりメリットが多いです。もしどうしてもインターフェースを使いたい場合は、内部で使います。上記のコードの場合、NextTime レシーバーを持つインターフェースを定義します。こうすることで、よくアクセスするデータに関してはシンプルなメンバー変数にアクセスすることになり、複雑なインターフェースや getter/setter を扱うことを少なくできます。

Discussion

ログインするとコメントできます