焼売飯店

GoとかTS、JSとか

ライブラリとして公開したGoのinterfaceを変更するのは難しいと言う話

昨日Twitterに書いた内容に、sivchariさんとhajimehoshiさんからリプライをいただいたので、備忘録的にまとめておきます。

発端

昨日、フューチャー技術ブログに掲載された、こちらのManoさんの記事を読みました。

future-architect.github.io

内容としてはGo 1.20で追加される見込みの context.WithCancelCausecontext.Cause に関するもので、気になったのは context.Cause 関数についての次の記述でした。

Cause()ですが、以下のように context.Context のインターフェースにCause()といった関数を追加してくれた方が利用者としては便利じゃないかと思いますよね。これはGo1互換性ポリシーに書いてあるように、パッケージエクスポートされたインターフェースに新しい関数を追加することは許可されてないということで否定されていました(そのため、context.Contextを引数にとる現在のかたちで提供されています)。

記事中で紹介されているコード例も引用させていただきます。

// 互換性をぶっ壊すAPIイメージ
type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key any) any
+   Cause() error // ★後方互換性を壊すためインターフェースに新規関数の追加はできない
}

これはとても共感できる内容で、ここで自分が持った疑問は "なぜcontext.Contextは構造体型ではなくinterface型で定義されているのか?" と言うものでした。

それでは、 context.Context のような公開された型に、後方互換性を破壊することなく Cause() メソッドのような公開メソッドを追加する方法について考えてみましょう。

interface型と非interface型の後方互換性について

まず、公開されたinterface型および、非interface型のそれぞれに変更を加えた場合、後方互換性にどういった影響があるか考えます。

interface型を公開した場合

以下のように、package x にメソッド A()B() を持つ interface型 I があります。

package x

type I interface {
    A()
    B()
}

このinterface型 I は外部に公開されており、メソッド名にも、メソッドのシグニチャにも、非公開な識別子が含まれていません。

そのため、外部のpackageから、package x を import した上で、 interface型 x.I を実装することが出来ます。 (package x のモジュール名は、便宜的に github.com/syumai/example とします)

package main

import "github.com/syumai/example/x"

type S struct{}

func (s S) A() {}
func (s S) B() {}

func main() {
    // OK: 型Sはinterface型 x.I を実装しているので代入可能
    var v x.I = S{}
    // OK: interface型 x.I のメソッド A() を呼び出している
    v.A()
}

ここで、package x の interface型 I に、次のような変更を行ってみます。

  1. メソッドの追加
  2. メソッドのシグニチャの変更
  3. メソッドの削除

(説明を簡略化するため、ここで変更を行う内容は、公開メソッドに関する情報に限定します)

1. メソッドの追加

interface I に、メソッド C() を追加してみます。

package x

type I interface {
    A()
    B()
    C() // メソッド C() を追加
}

すると、package x の利用者側に定義していた型 S が、 interface x.I を実装していないことになってしまい、ビルドに失敗します。

package main

import "github.com/syumai/example/x"

// 型Sにはメソッド C() が無い
type S struct{}

func (s S) A() {}
func (s S) B() {}

func main() {
    // NG: 型Sはinterface型 x.I を実装していないため代入不可
    var v x.I = S{}
    v.A()
}

2. メソッドのシグニチャの変更

以下のように、メソッド A() のシグニチャを変更してみます。

package x

type I interface {
    A(i int) // メソッド A() にint型のパラメータを追加
    B()
}

すると、メソッドの追加と同様に、型 S が interface x.I を実装していないことになり、ビルドに失敗します。

さらに、 v.A() の箇所でinterface型 x.I に対するメソッド A() の呼び出しもあり、ここでの引数が足りていないと言う点でも失敗しています。

package main

import "github.com/syumai/example/x"

type S struct{}

// interface型 x.I のメソッド A() に存在するint型のパラメータが存在しない
func (s S) A() {}
func (s S) B() {}

func main() {
    // NG: 型Sはinterface型 x.I を実装していないため代入不可
    var v x.I = S{}
    // NG: interface型 x.I のメソッド A(i int) への引数が足りていない
    v.A()
}

3. メソッドの削除

最後に、メソッド A() を削除してみます。

package x

type I interface {
    // メソッド A() を削除
    B()
}

実は、このケースでは、型 S はinterface型 x.I を満たしたままとなるので、代入は可能です。

しかし、interface型 x.I のメソッド A() を呼び出そうとする箇所で失敗します。

package main

import "github.com/syumai/example/x"

type S struct{}

func (s S) A() {}
func (s S) B() {}

func main() {
    // OK: 型Sはinterface型 x.I を実装しているので代入可能
    var v x.I = S{}
    // NG: interface型 x.I には A() というメソッドが存在しない
    v.A()
}

公開されたinterface型を変更する場合の後方互換性についてのまとめ

公開されたinterface型を変更した場合、

  1. メソッドの追加
  2. メソッドのシグニチャの変更
  3. メソッドの削除

のいずれの場合も後方互換性を失う、破壊的変更となります。ここから、公開されたinterface型の持つ、公開された情報を変更することは出来ないと言い切ってしまってもよいです。

変更する対象のinterface型が1リポジトリ内でしか使われていない場合は、あえてビルドに失敗させることで、コードの修正が必要な箇所を見付け出すことに使うと言うテクニックがあります。

一方で、ライブラリとして公開しているinterfaceについては、ライブラリのバージョンをアップデートするだけでビルドが通らなくなると言う状況が発生してしまうため、ここで挙げたような破壊的変更を行うことは基本的に出来ません。

冒頭で紹介したcontext packageの例は、まさにこの "ライブラリとして公開しているinterface" のパターンで、Context型のinterfaceにメソッドを追加すると、Goのバージョンを上げるだけで過去に動作していたプログラムのビルドが出来なくなるため、そのような変更は出来ない、と言う話になります。

非interface型を公開した場合

次に、非interface型について同じ内容を検討してみます。

ここでは、interface型 I を定義せず、 package x が直接非interface型 T を公開するものとします。 型定義の対象は非interface型であれば基本的に何でもよいのですが、今回はフィールドを持たない構造体型を使用します。

package x

type T struct{}

func (T) A() {}
func (T) B() {}

先ほど同様、外部のpackageから、package x を import した上で、 型 x.T を使用します。

package main

import "github.com/syumai/example/x"

func main() {
    var v x.T{}
    // OK: 型 x.T はメソッド A() を持っている
    v.A()
}

1. メソッドの追加

T に、メソッド C() を追加してみます。

package x

type T struct{}

func (T) A() {}
func (T) B() {}
func (T) C() {}

メソッドの追加は、下記のコードの通り、package x を利用する側が必要とする情報に何も影響を与えません。

したがって、非interface型については、メソッドの追加は後方互換性を破壊しません。

package main

import "github.com/syumai/example/x"

func main() {
    var v x.T{}
    // OK: 型 x.T はメソッド A() を持っている
    v.A()
}

2. メソッドのシグニチャの変更

以下のように、メソッド A() のシグニチャを変更してみます。

package x

type T struct{}

func (T) A(i int) // メソッド A() にint型のパラメータを追加
func (T) B() {}

すると、 v.A() の箇所でメソッド A() に対する引数が足りていないと言う点で失敗します。

package main

import "github.com/syumai/example/x"

func main() {
    var v x.T{}
    // NG: メソッド A() に対する引数が足りない
    v.A()
}

3. メソッドの削除

最後に、メソッド A() を削除してみます。

package x

type T struct{}

// メソッド A() を削除
func (T) B() {}

すると、 T型に存在しないメソッド A() を呼び出そうとする箇所で失敗します。

package main

import "github.com/syumai/example/x"

func main() {
    var v x.T{}
    // NG: 型 T には A() というメソッドが存在しない
    v.A()
}

公開された非interface型を変更する場合の後方互換性についてのまとめ

非interface型の場合は、メソッドの追加で後方互換性が崩れません。 メソッドのシグニチャの変更や、削除ではinterface型同様破壊的変更となりますが、メソッドの追加と言う形での機能追加を検討している場合、非interface型を採用することが有力な選択肢となります。

その他の後方互換性を崩さない機能拡張のパターンの紹介

他から実装できないinterfaceにする

hajimehoshiさんに紹介いただいたパターンです。

非公開メソッドのあるinterfaceは、同じpackage以外から実装できないと言う性質を利用します。

package x

type I interface {
    A()
    B()

    // 非公開メソッドのあるinterfaceは、同じpackage以外から実装できない
    private()
}

下記のように、外部から絶対に実装できないinterfaceとなるため、後からメソッドを追加する分には破壊的変更とはなりません。

package main

import "github.com/syumai/example/x"

type S struct{}

func (s S) A() {}
func (s S) B() {}
// privateと言う名前のメソッドを追加しても `x.I` を実装したことにならない
func (s S) private() {}

func main() {
    // NG: 型Sはinterface型 x.I を実装していない
    var v x.I = S{}
}

実装の詳細の隠蔽のためにinterfaceを使いたいが、そのinterfaceを外部から実装されたくないケースで有効です。

interfaceを合成する

sivchariさんに紹介いただいたパターンです。

interfaceそのものを変更することが出来ないので、別のinterfaceを追加して合成していくパターンです。

元々存在する A() メソッドを持ったinterfaceをベースとして、 B() メソッドを持つinterface、C() メソッドを持つinterfaceを後から追加し、合成していきます。

type Aer interface {
    A()
}

type Ber interface {
    B()
}

type Cer interface {
    C()
}

type ABer interface {
    A
    B
}

type ACer interface {
    A
    C
}

ライブラリを利用するユーザーに、使うinterfaceを切り替えることを要求できる環境であれば、Aerの代わりにABerやACerを使うようアナウンスする形での対応で十分と言えるでしょう。加えて、Aerをdeprecateして、将来のバージョンでABerやACerのみを残すことも可能です。

内部用のinterfaceを分け、構造体型を公開する

自分の書いたパターンです。

内部的にだけ実装できるinterfaceを分け、それを公開する構造体のフィールドに保持します。単に i を埋め込む形でもよいですが、それではgo docにメソッドの情報が出力されないので、下記のように明示的にT型にメソッドを宣言することをおすすめします。

package x

type i interface {
    A()
    B()
}

type T struct {
    i: i,
}

var _ i = T{}

func (t T) A() { t.i.A() }
func (t T) B() { t.i.B() }

このようにすると、Tの内部実装をt1, t2など非公開の型として実装して使い分けるといったことが可能です。

package x

type t1 struct{}
func (t1) A() {}
func (t1) B() {}

type t2 struct{}
func (t2) A() {}
func (t2) B() {}

func NewT1() T {
    return T{
        i: t1{},
    }
}

func NewT2() T {
    return T{
        i: t2{},
    }
}
package main

import "github.com/syumai/example/x"

func main() {
    t1 := x.NewT1()
    t1.A()

    t2 := x.NewT2()
    t2.B()
}

非公開のinterface型 i を変更することで、後方互換性を維持しつつメソッドを追加することも可能です。

package x

type i interface {
    A()
    B()
    // interface i は外部から実装出来ないのでメソッドの追加が可能
    C()
}

まとめ

重要なのは下記の2つです。

  • 公開されたinterface型の持つ、公開された情報を変更することは出来ない
  • 非interface型の場合は、メソッドの追加で後方互換性が崩れない

また、後方互換性を崩さない形でのメソッドの追加にはいくつか方法があるので、自分の実装しているライブラリの方針に合った方法を見付けて実践してみてください!

記事中では取り上げませんでしたが、当然ながら、ライブラリのメジャーバージョン(ライブラリによってはマイナーバージョン)を上げつつ破壊的変更を加えることも選択肢に入ります。

他にも何かよい方法があれば、記事へのコメントや、Twitterでリプライなどいただけると嬉しいです! (まだまだ沢山あると思います!)