“Preemptive interface Anti-Pattern in Go” とは?


はじめに

GoにはPreemptive interface Anti-Pattern in Goという考え方があります。

https://medium.com/@cep21/preemptive-interface-anti-pattern-in-go-54c18ac0668a

私はこちらの記事を読み初めて知りました。

ただ、原文を読んだだけでは理解が難しかったので自分なりに手を動かして自分の言葉で整理してみたのが本記事の内容となります。 英語力に自信がなく、自動翻訳の力を借りています。また、間違っている可能性もあるため真の情報としては原文のご確認をお願いします。 (本記事の内容が間違っていたらご指摘のほどお願いします。)

TL;DR

  • Preemptive Interfaceという実装パターンがあります。
  • Javaのようなinterfaceを明示的に継承させる必要がある言語では有効となります。
  • Goではinterfaceは暗黙的に継承されるのでそこまで有効ではありません。

Preemptive Interfaceとは?

そもそもPreemptive Interfaceとはどのような実装パターンなのでしょうか? 強引な訳かもしれませんが、日本語では「先走ったインターフェイス」と言えるでしょうか。 実際にinterfaceが必要かどうか判明する前にあらかじめinterfaceを実装するパターンとなります。 噛み砕いた表現をすると、「とりあえずinterface用意しておいた方が無難だよね」という感じでしょうか。

以下のような実装がPreemptive Interfaceとなります。

type IUser interface {
  Hello() error
}

type User struct {}

func NewUser() IUser {
  return User{}
}

func (u User) Hello() error {
    return nil
}

これがGoにおいてはアンチパターンとなる場合があるのだということです。 どういうことなのでしょうか?

なぜとりあえずinterfaceを用意しておきたくなるのか

実際にJavaGoのコードを実装してとりあえずinterfaeを用意しておきたくなる理由を紐解いてみます。

以下のような機能を有した実装がすでにあったと仮定します。

  • boolを返すCanAction()というAuth型があります。
  • Auth型を引数に持つTakeAction(a Auth)というメソッドがあります。

このTakeAction(a Auth)関数の引数をinterfaceにして汎用性を高めたくなりました。 修正の目的としてはテストを実施する際にMockが使いたくなったとかになるでしょうか。

では実際の実装を用いてJavaGoを比較していきます。 なるべく比較しやすいようにJavaGoが似たような実装になるようにしています。 原文とは異なる実装を用いていること、ご了承ください。

Java

まずはJavaから見ていきます。

Sample実装(修正前)

Auth.java

package auth;

public class Auth {
    public boolean canAction() {
        return true;
    }
}

Logic.java

package logic;

import auth.Auth;

public class Logic {
    public void takeAction(Auth auth) {
        if (auth.canAction()) {
            System.out.println("take action");
        }
    }
}

Main.java

import auth.Auth;
import logic.Logic;

public class Main {
    public static void main(String[] args) {
        final var auth = new Auth();

        final var logic = new Logic();

        logic.takeAction(auth);
    }
}

Sample実装(修正後)

Auth.java

package auth;

import iauth.IAuth;

public class Auth implements IAuth {
    public boolean canAction() {
        return true;
    }
}

IAuth.java

package iauth;

public interface IAuth {
    public boolean canAction();
}

Logic.java

package logic;

import iauth.IAuth;

public class Logic {
    public void takeAction(IAuth auth) {
        if (auth.canAction()) {
            System.out.println("take action");
        }
    }
}

Main.java

import auth.Auth;
import logic.Logic;

public class Main {
    public static void main(String[] args) {
        final var auth = new Auth();

        final var logic = new Logic();

        logic.takeAction(auth);
    }
}

変更点

  • IAuthを追加
  • takeActionの引数をAuthからIAuthに変更
  • Authimplement Authを追加

Go

続いてGoの実装を見ていきます。

Sample実装(修正前)

auth.go

package auth

type Auth struct{}

func NewAuth() Auth {
	return Auth{}
}

func (a Auth) CanAction() bool {
	return true
}

logic.go

package logic

import (
	"log"
	"preemptive-interface/auth"
)

type Logic struct{}

func NewLogic() Logic {
	return Logic{}
}

func (l Logic) TakeAction(auth auth.Auth) {
	if auth.CanAction() {
		log.Println("take action")
	}
}

main.go

package main

import (
	"preemptive-interface/auth"
	"preemptive-interface/logic"
)

func main() {
	a := auth.NewAuth()

	l := logic.NewLogic()

	l.TakeAction(a)
}

Sample実装(修正後)

auth.go

package auth

type Auth struct{}

func NewAuth() Auth {
	return Auth{}
}

func (a Auth) CanAction() bool {
	return true
}

iauth.go

package iauth

type IAuth interface {
	CanAction() bool
}

logic.go

package logic

import (
	"log"
	"preemptive-interface/iauth"
)

type Logic struct{}

func NewLogic() Logic {
	return Logic{}
}

func (l Logic) TakeAction(auth iauth.IAuth) {
	if auth.CanAction() {
		log.Println("take action")
	}
}

main.go

package main

import (
	"preemptive-interface/auth"
	"preemptive-interface/logic"
)

func main() {
	a := auth.NewAuth()

	l := logic.NewLogic()

	l.TakeAction(a)
}

変更点

  • IAuthを追加
  • takeActionの引数をAuthからIAuthに変更

JavaGoを比較

一見すると言語仕様に応じてJavaではimplementsを用いて明示的に継承しているが、Goでは暗黙的に継承(ダックタイピング)であるかくらいの違いしかありません。 これだけだとそこまで修正に対してコストが変わらないように感じます。 しかし、ある状況下だとJavaではこの修正が簡単にはいかないことがあります。

私(あなた)Logicの実装者でAuth別の方 が実装しているとします。(私(あなた)が簡単にAuthを修正できない状況です。) このときLogicを上記で実装したように修正した場合、Auth実装者に「implementsを用いて継承するようにしてください」と連絡/同意をする必要があります。 でないとビルドが通らなくなります。(Main.javaでビルドエラーが発生し破壊的変更となります。) このような事態がJavaでは発生するため、あらかじめinterfaceを用意しておくPreemptive Interfaceが有効と言えます。

一方、Goの場合はというと暗黙的な継承(ダックタイピング)となるためLogic実装者が一方的に上記のような修正を行なうことができます。(main.goのビルドは落ちず、後方互換性を保つことができ破壊的変更にはなりません。) そのため、GoではPreemptive Interfaceはアンチパターンですよ。ということになるのですね。 ≒ そんなことをしておかなくてもあとで簡単に変更ができます。不要なものはなるべく作らない。YAGNI原則ですね。

おわりに

原文の終盤ではPreemptive Interfaceを用いるとメソッド数が増加する傾向にあると言及されています。 クリーンアーキテクチャにおけるUsecaseRepositoryの関係性などを思い浮かべるとよいでしょうか。

一般的にinterfaceはシンプルである(メソッドが少ない)方が有利な点が多いかと思います。 シンプルになったりDIがしやすくなったりといったメリットが挙げられるでしょうか。

Goの標準パッケージでもinterfaceのメソッドは1つないし2つが一般的なコードとなっています。

https://go.dev/doc/effective_go#interfaces

こちらはGoでのAccept Interfaces, return structsという考え方を整理することでもう少し理解しやすくなる内容かと思います。 (Accept Interfaces, return structsは日本語での記事も多数あるのでそちらを参照ください。機会があれば私も整理するつもりです。)

https://bryanftan.medium.com/accept-interfaces-return-structs-in-go-d4cab29a301b

実際にJavaGoを比較しPreemptive Interfaceを確認したことでGoらしさを確認できたと思います。

一方でプロダクトコード開発において今回のようにstructが引数に与えられていてそこから修正するというシチュエーションに遭遇するのか?という疑問も持ちました。 比較的Mockを利用したテストを想定してコーディングするために最初からPreemptive Interfaceで実装することも多いかと思います。

Goらしさとプロダクトコード開発のバランスについてはまだまだ理解できていないので今後もいろいろと整理していきたいです。