1. はじめに
昔、オブジェクト指向の記事を書いた時、ポリモーフィズムの実装のために使われるインターフェースを記述したことがあるのでインターフェース自体の理解はあったつもりだったのですが、Goではどのようにインターフェースが実装されるのかや、これがどのように処理の抽象化につながるのか(特にDI)ということについて理解が甘かったです。
今回参加したプロジェクトでインターフェースやDIを利用する機会があったので、忘備録も兼ねて自分なりの理解をまとめてみようと思います。
目次
2. インターフェース
インターフェースはメソッドの束を定義し、データを抽象化したりポリモーフィズムを実装するためなどに使われます。ポリモーフィズムはインターフェースを適用するクラスに対して、インターフェースに記述されたメソッドの実装を要求します。即ち、インターフェースはその具体的な実装をもたず、ただどのようなメソッドを定義するべきかを指示するだけです。
Goのインターフェースは、例えば以下のように実装することができます。
package interfaces
import (
"fmt"
)
type Member interface{
Greet() string
Work() string
}
type Staff struct{
Name string
Age int
Client string
}
func (s Staff) Greet() string {
return fmt.Sprintf("My name is %s. I'm %d years old.", s.Name, s.Age)
}
func (s Staff) Work() string{
return fmt.Sprintf("I sell a commodity for %s.", s.Client)
}
// ここまででは、interfaceにまとめられたメソッドと(たまたま)同名同型のメソッドをHumanに実装しているに過ぎません
func (s Staff) Farewell() string{
return "Bye bye"
}
func Routine(c Member){
g := c.Greet()
w := c.Work()
fmt.Println(g, w)
}
Routine関数はStaff構造体ではなくMemberインターフェースを引数に取ります。
インターフェースは引数にも返り値にも設定することができるということは重要です。
以下、スタッフ構造体をインスタンス化して、Routine関数を用いるところです。
func main(){
s := interfaces.Staff{
Name: "memeru",
Age: 35,
Client: "tatara",
}
interfaces.Routine(s)
s.Work()
fmt.Println(s.Farewell())
}
この場合、無事にRoutine関数内のGreetメソッドとWorkメソッドが機能しました。
インスタンスからメソッドを直接呼び出しても同じです。
このようにインターフェースは、最低限インターフェースに定義されたメソッドを実装していることを求めます。
Routine関数がスタッフ構造体をMemberインターフェースを実装したものとみなすためには、最低限関数の型や名前が一致するGreetメソッドやWorkメソッドがあれば大丈夫です。
なお、インターフェースとして判断するために、インターフェースの定義にはない余計なメソッドがあっても構いませんが、例えばGreetメソッドがないなどの場合はRoutine関数に渡す際にエラーが出てきます。
TypeScriptやJavaなど、他のオブジェクト指向型言語の言語からGoに入門した方(私も含めて)は特に、インターフェースが構造体に対して、インターフェースがもつメンバーの実装を強制するものではないということに戸惑ったことがあるかもしれません。Goではどちらかといえば、インターフェースとしてその構造体を使う時に初めて、メンバーが実装されているかどうかをチェックします。それまで、インターフェースと構造体に備えられたメソッドとの間には、何も関係性が存在しないのです。
例えば、TypeScriptでは以下のようにインターフェースは実装されます。
interface Getter {
religion: string;
getName(): string;
getAge(): number;
}
class Member implements Getter{
private name: string;
private age: number;
public religion: string;
constructor(name: string, age:number, religion:string){
this.name = name
this.age = age
this.religion = religion
}
public getName():string {
return this.name;
}
public getAge():number{
return this.age
}
public setName(newName:string){
this.name = newName
}
}
TypeScriptでのインターフェースはメソッドだけでなくプロパティも実装を強制させられます。
Getterというインターフェースを実装したHumanというクラスは、必ずしもGetterに定められたメソッドだけを定義しているわけではありませんが、少なくともreligionとgetName、getAgeの三つは備えることが保障されています。
このように、TypeScriptではクラスを定義する際、このインターフェースを実装すると明示していますが、Goにおいては構造体のメソッドを定義している段階では単に同型同名のメソッドを記述しているにすぎません。しかし、実際の挙動としては、構造体がインターフェースに定義されたメソッドを全て持っていれば、自動的にそのインターフェースを実装していると見なされます。独立でありながらインターフェースを実装したことになる、とても把握しづらい関係です。
ここら辺の感覚の違いが、Goのインターフェースの理解を難しくしている要因なのかなと思いました。
3. DI
依存性注入(DI)もこれ一本で記事が作られるくらい内容的には重いのですが、インターフェースとの関連性も高いのでここでまとめておきます。
依存性注入とは、あるオブジェクトや関数が依存する他のオブジェクトや関数を受け取るデザインパターンで、疎結合なプログラムを構築することを意図しているそうです。
密結合はいわば部品の取り外しがきかない自動車のようなもので、タイヤを変えるには一度、部分的にでも解体が必要になります。これに対して疎結合は、コンポーネントが組み合わさってできる(普通の)自動車のようなもので、タイヤを変えるにはナットを外して他のタイヤに付け替えるだけで完了です。
DIの解説に入る前に、DIでは「インターフェースに依存する」ことが必要なのですが、これがなかなか抽象的で把握しがたかったので、まず単純な例をもとにインターフェースへの依存がどういうものかをお示ししようと思います。
まず、密結合の例です
type B struct {
}
type A struct {
b B // 直接依存している
}
func (a *A) DoSomething() {
a.b.SomeMethod()
}
次に、疎結合の例です
type Greet interface {
Greeting(message string) error
}
type B struct {
}
func (b *B) Greeting(message string) error {
return nil
}
type A struct {
greet Greet // インターフェースに依存
}
func (a *A) DoSomething() {
a.greet.Greeting("Hello!")
}
構造体Aのメンバーが、構造体BからGreetインターフェースに変わったことがわかるでしょうか。
これが「インターフェースに依存する」ということであり、構造体Aが他の構造体を受け取るといった際、構造体Bに限定されずGreetingというメソッドを備えたものであれば何でも受け取れるようになります。構造体Bに限定されていたのが代えの利かないタイヤのような状態で、インターフェースに依存していたのが規格にあっていればなんでも交換できるコンポーネント化したタイヤのような状態です。
いよいよDIの具体的な実装に移っていきます。ここでは密結合になっているときと、DIを使った疎結合の場合とを比較することで理解を深めていこうと思います。
まず、密結合の場合です。
具体例として、通知を送る機能を実装する場合を考えます。ここでは、Emailで送る方法とSMSで送る方法の二つを想定します。
Emailの例です。
type EmailNotifier struct{}
func (e EmailNotifier) Send(message string) error {
fmt.Printf("%s via Email...",message)
return nil
}
次にSMSの例です。
type SMSNotifier struct{}
func (e SMSNotifier) Send(message string) error {
fmt.Printf("%s via SMS...",message)
return nil
}
そして、各種通知手段をまとめて扱うNotificationServiceを実装します。
type NotificationService struct {
EmailNotifier EmailNotifier
SMSNotifier SMSNotifier
}
func (n *NotificationService) Notify(options string, message string) error {
switch options{
case "mail":
return n.EmailNotifier.Send(message)
case "sms":
return n.SMSNotifier.Send(message)
default:
return errors.New("invalid method")
}
}
ここでは、NotificationServiceがEmailNotifierやSMSNotifierなどの構造体に依存しており、密結合してしまっているために、二つのNotifierを同時に扱えるようにしようとするとNotifyメソッドの内部が複雑になります。
またそれ以外にも問題はあって、この形の実装では新しく通知方法を開発しようとした際その構造体を追加するのはもちろん、同時にNotifyメソッドの中身まで実装を変えなければなりません。もしNotifyメソッドの変更を忘れてしまった場合、想定していなかった用途で使われることになるので、バグの原因になります。しかし、Notifyが別のファイルにあった場合など、往々にして変更を忘れることはあるかもしれません。このような点が密結合の欠点です。
最後に、実際の呼び出し処理を見て終わります。
func main(){
service2 := dependencyInjection.NotificationService{
EmailNotifier: dependencyInjection.EmailNotifier{},
SMSNotifier: dependencyInjection.SMSNotifier{},
}
service.Notify("sms","Hello")
}
次に、DIを使った疎結合の場合です。
まず、Notifierというインターフェースを作り、各種の構造体はそのインターフェースを満たすようなメソッドを備えるようにします。
package dependencyInjection
import "fmt"
type Notifier interface {
Send(message string) error
}
type EmailNotifier struct{}
func (e EmailNotifier) Send(message string) error {
fmt.Println(message)
return nil
}
type SMSNotifier struct{}
func (s SMSNotifier) Send(message string) error {
fmt.Println(message)
return nil
}
次に、通知手段をまとめた構造体をつくります。
package dependencyInjection
import "fmt"
type NotificationService interface{
Notify(message string) error
}
type notificationService struct{
notifier Notifier
}
func NewNotificationService(notifier Notifier) NotificationService{
return ¬ificationService{notifier: notifier}
}
func (n notificationService) Notify(message string) error{
return n.notifier.Send(message)
}
ここで、構造体はnotificationServiceとして外部提供を避けており、代わりにコンストラクター関数の返り値としてNotificationServiceというインターフェースを定義しています。
これにより、呼び出し元と構造体notificationServiceの結合が疎になるのはもちろん、この構造体にNotifyメソッドを実装することを忘れてしまった場合、エラーが出るようになります。なぜなら、構造体のインスタンスはNotificationServiceインターフェースを満たす必要があり、このインターフェースをみたすということは、Notifyメソッドを備えている必要があるからです。挙動だけを見れば、実装段階でメソッドを備えているか検証できるので、構造体に対してImplementsしているみたいなものと言えるかもしれません。
最後に、呼び出し元のコードを見て終わります。
func main(){
emailNotifier := dependencyInjection.EmailNotifier{}
service := dependencyInjection.NewNotificationService(emailNotifier)
service.Notify("Hello via Email!")
smsNotifier := dependencyInjection.SMSNotifier{}
service = dependencyInjection.NewNotificationService(smsNotifier)
service.Notify("Hello via SMS!")
}
密結合だった場合と違って、smsNotifierとemailNotifierの二つのインスタンスを作って別個に渡す必要がありますが、逆に言えば新しい通知方法を実装したとしても、Sendメソッドを備えている限り他のコードを変更する必要なくその通知方法を実装することができます。
このようになるのは(繰り返すようで恐縮ですが)、notificationServiceという構造体が他の構造体に依存しているのではなく、Notifierというインターフェースに依存しており、そのインターフェースを満たす限りにおいて、引数として受け取るインスタンスにSendメソッドが備わっていることが保障されるからです。
これは、コードの保守性を高めるのみならず、モックを代わりに注入することもできるのでテストも容易になります。色々メリットがある、非常に便利なデザインパターンですね。
4. おわりに
私は、ここら辺のことが理解できていないままレイヤードアーキテクチャーとDDDを使った実装に触れてしまったので、かなり写経になってしまっていました。一応、DIがどういう状態を指すものでGoにおいてどのように実装できるかは理解できたのでよかったです。
感覚的には、SOLID原則のオープンクローズドの原則を満たすもののような気がしました。
5. 参考