この記事は Go Advent Calendar 2022 23日目の記事です。
はじめに
Go言語はJavaなどのオブジェクト指向のように明示的な継承関係がない分、interfaceをうまく使いこなせるかがカギになってくるように個人的に感じます。
そのinterfaceの使い方は、オブジェクト指向におけるStrategyパターンが参考になるよ、という話をしようと思います。
読者としてはGoに入門したばかりの初学者を想定しています。
Strategyパターン
Strategyパターンは「GoFのデザインパターン」のなかで紹介されている、クラスやインタフェースの使い方に関する設計パターンです。
図式
Strategyパターンの説明については、結城浩『Java言語で学ぶデザインパターン入門 第3版』に詳しいのでそのまま引用します。
strategyというのは、「戦略」という意味です。敵をやっつけるときの作戦、軍隊を動かすときの方策、それに問題を解いていくときの方法、そういった意味合いを持っています。プログラミングの場合には「アルゴリズム」と考えてもいいでしょう。
どんなプログラムも何らかの問題を解くために書かれており、その問題を解くために特定のアルゴリズムが実装されています。Strategyパターンでは、実装したアルゴリズムをごっそりと交換できるようになっています。アルゴリズム(戦略・作戦・方策)をカチッと切り替え、同じ問題を別の方法で解くのを容易にするパターン、それがStrategyパターンなのです。
Strategy(戦略)
戦略を利用するためのインタフェースを定める役です。
ConcreteStrategy(具体的戦略)
Strategy役のインタフェースを実際に実装する役です。ここで具体的な戦略(作戦・方策・方法・アルゴリズム)を実際にプログラミングします。
Context(文脈)
Strategy役を利用する役です。ConcreteStrategy役のインスタンスを持っていて、必要に応じてそれを利用します(あくまでも呼び出すのはStrategy役のインタフェース)。
じゃんけんサンプルプログラム:JavaとGoの比較
Strategyパターンについての詳しい説明は、書籍やネット記事に溢れているのでそちらに譲ることにして、実際のJavaとGoのコードを見比べながら、GoにおけるStrategyパターンの実現の仕方を見ていこうと思います。
Javaサンプルコードについては上述『Java言語で学ぶデザインパターン入門 第3版』からお借りしています。
サンプルプログラムの内容
2人のプレイヤーがじゃんけんを行う。じゃんけんの手を出す戦略として、WinningStrategy
とProbStrategy
という方法を考える。
一方のプレイヤーはWinningStrategy
に従って手を出し、他方はProbStrategy
に従って手を出すとしたら、どちらの勝率が高いかということをシミュレーションするプログラム。
-
Hand
:じゃんけんの「手」を表すクラス -
Strategy
:じゃんけんの「戦略」を表すインタフェース -
WinningStrategy
:勝ったら次も同じ手を出す戦略を表すクラス -
ProbStrategy
:1回前の手から次の手を確率的に計算する戦略を表すクラス -
Player
:じゃんけんを行うプレイヤーを表すクラス -
Main
:動作テスト用のクラス
以下のソースコードは、説明のために一部を抜粋したものです。
全量についてはGitHubで公開しているので、そちらもご参照ください。
Hand型
Java
public enum Hand {
// じゃんけんの手を表す3つのenum定数
ROCK("グー", 0),
SCISSORS("チョキ", 1),
PAPER("パー", 2);
}
Go
- Goにはenum型がないので、intを拡張した型(Hand型)を定義し、const識別子iotaでインクリメントする
type Hand int
// じゃんけんの手を表す3つの定数
const (
Rock Hand = iota // 0
Scissors // 1
Paper // 2
)
Strategy(インタフェース)
-
nextHand
:「次に出す手を取得する」ためのメソッド -
study
:「さっき出した手によって勝ったどうか」を学習するためのメソッド
Java
public interface Strategy {
public abstract Hand nextHand();
public abstract void study(boolean win);
}
Go
type Strategy interface {
NextHand() Hand
Study(win bool)
}
WinningStrategy(実装)
- 前回の勝負に勝ったら次も同じ手を出す
- 前回の勝負に負けたら次の手は乱数を使って決定する
Java
-
Strategy(インタフェース)
で定義した2つのメソッド(nextHand()
,study()
)をオーバーライドする
public class WinningStrategy implements Strategy {
private Random random;
private boolean won = false;
private Hand prevHand;
public WinningStrategy(int seed) {
random = new Random(seed);
}
@Override
public Hand nextHand() {
if (!won) {
prevHand = Hand.getHand(random.nextInt(3));
}
return prevHand;
}
@Override
public void study(boolean win) {
won = win;
}
}
Go
- Javaのように明示的な
implements
や@Override
のようなキーワードはない - そのかわりに構造体の型(
WinningStrategy
)に対して、Strategy(インタフェース)
で定義した2つのメソッド(NextHand()
,Study()
)を追加してあげることで、WinningStrategy
はStrategy(インタフェース)
である条件を満足する(WinningStrategy型はStrategyインタフェースを実装していることになる)
type WinningStrategy struct {
random *rand.Rand
won bool
prevHand Hand
}
func NewWinningStrategy(seed int64) *WinningStrategy {
return &WinningStrategy{
random: rand.New(rand.NewSource(seed)),
}
}
func (s *WinningStrategy) NextHand() Hand {
if !s.won {
s.prevHand = Hand(s.random.Intn(3))
}
return s.prevHand
}
func (s *WinningStrategy) Study(win bool) {
s.won = win
}
ProbStrategy(実装)
- 過去の勝ち負けの履歴を使って、それぞれの手を出す確率を変える
細かい実装は重要でなくて、ProbStrategy
がWinningStrategy
と同様にStrategy(インタフェース)
で定義した2つのメソッドを実装していることが重要。
Java
public class ProbStrategy implements Strategy {
private Random random;
private int prevHandValue = 0;
private int currentHandValue = 0;
private int[][] history = {
{ 1, 1, 1, },
{ 1, 1, 1, },
{ 1, 1, 1, },
};
public ProbStrategy(int seed) {
random = new Random(seed);
}
@Override
public Hand nextHand() {
int bet = random.nextInt(getSum(currentHandValue));
int handvalue = 0;
if (bet < history[currentHandValue][0]) {
handvalue = 0;
} else if (bet < history[currentHandValue][0] + history[currentHandValue][1]) {
handvalue = 1;
} else {
handvalue = 2;
}
prevHandValue = currentHandValue;
currentHandValue = handvalue;
return Hand.getHand(handvalue);
}
@Override
public void study(boolean win) {
if (win) {
history[prevHandValue][currentHandValue]++;
} else {
history[prevHandValue][(currentHandValue + 1) % 3]++;
history[prevHandValue][(currentHandValue + 2) % 3]++;
}
}
private int getSum(int handvalue) {
// 省略
}
}
Go
type ProbStrategy struct {
random *rand.Rand
prevHand Hand
currentHand Hand
history [][]int
}
func NewProbStrategy(seed int64) *ProbStrategy {
return &ProbStrategy{
random: rand.New(rand.NewSource(seed)),
prevHand: 0,
currentHand: 0,
history: [][]int{
{1, 1, 1},
{1, 1, 1},
{1, 1, 1},
},
}
}
func (s *ProbStrategy) NextHand() Hand {
bet := s.random.Intn(s.getSum(s.currentHand))
var hand Hand
if bet < s.history[s.currentHand][0] {
hand = Rock
} else if bet < s.history[s.currentHand][0]+s.history[s.currentHand][1] {
hand = Scissors
} else {
hand = Paper
}
s.prevHand = s.currentHand
s.currentHand = hand
return hand
}
func (s *ProbStrategy) Study(win bool) {
if win {
s.history[s.prevHand][s.currentHand]++
} else {
s.history[s.prevHand][(s.currentHand+1)%3]++
s.history[s.prevHand][(s.currentHand+2)%3]++
}
}
func (s *ProbStrategy) getSum(hand Hand) int {
// 省略
}
Player
Java
public class Player {
private String name;
private Strategy strategy;
// 名前と戦略を授けてプレイヤーを作る
public Player(String name, Strategy strategy) {
this.name = name;
this.strategy = strategy;
}
// 戦略におうかがいを立てて次の手を決める
public Hand nextHand() {
return strategy.nextHand();
}
}
Go
- 構造体のフィールドに
Strategy(インタフェース)
を持っている - 次の手を決めるときには
Strategy(インタフェース)
のNextHand()
メソッドを呼び出す
type Player struct {
name string
strategy Strategy
}
// 名前と戦略を授けてプレイヤーを作る
func NewPlayer(name string, strategy Strategy) *Player {
return &Player{
name: name,
strategy: strategy,
}
}
// 戦略におうかがいを立てて次の手を決める
func (p *Player) NextHand() Hand {
return p.strategy.NextHand()
}
Main
Java
public class Main {
public static void main(String[] args) {
// Taroは、WinningStrategyを利用
Player player1 = new Player("Taro", new WinningStrategy(314));
// Hanaは、ProbStrategyを利用
Player player2 = new Player("Hana", new ProbStrategy(15));
// 10000回勝負
for (int i = 0; i < 10000; i++) {
Hand nextHand1 = player1.nextHand();
Hand nextHand2 = player2.nextHand();
if (nextHand1.isStrongerThan(nextHand2)) {
System.out.println("Winner:" + player1);
} else if (nextHand2.isStrongerThan(nextHand1)) {
System.out.println("Winner:" + player2);
} else {
System.out.println("Even...");
}
}
}
}
Go
- PlayerをNewするこのタイミングで、どちらのStrategyを利用するかを設定している(それまではPlayer自身がどちらのStrategyを利用するか一切知る必要がない、というのがポイント)
- すなわち依存オブジェクトの注入をしてあげている(この場合はConstructor Injectionに該当し、PlayerのコンストラクタでStrategyインタフェースの実装を注入している)
func main() {
// Taroは、WinningStrategyを利用
player1 := NewPlayer("Taro", NewWinningStrategy(314))
// Hanaは、ProbStrategyを利用
player2 := NewPlayer("Hana", NewProbStrategy(15))
// 10000回勝負
for i := 0; i < 10000; i++ {
nextHand1 := player1.NextHand()
nextHand2 := player2.NextHand()
if nextHand1.IsStrongerThan(nextHand2) {
fmt.Printf("Winner:%v\n", player1)
player1.Win()
player2.Lose()
} else if nextHand2.IsStrongerThan(nextHand1) {
fmt.Printf("Winner:%v\n", player2)
player1.Lose()
player2.Win()
} else {
fmt.Println("Even...")
player1.Even()
player2.Even()
}
}
}
おわりに
DIP(依存関係逆転原則)とのつながり
Strategyパターンは、SOLID原則の"D"である依存関係逆転の原則(Dependency Inversion Principle)と深い関連があります。
Robert C. Martin『Clean Architecture』を引用します。
オブジェクト指向とは「ポリモーフィズムを使用することで、システムにあるすべてのソースコードの依存関係を絶対的に制御する能力」である。
これにより、アーキテクトは「プラグインアーキテクチャ」を作成できる。これは、上位レベルの方針を含んだモジュールを下位レベルの詳細を含んだモジュールから独立させることである。下位レベルの詳細はプラグインモジュールとなり、上位レベルの方針を含んだモジュールとは独立して、デプロイおよび開発することが可能となる。
この図ではHL1
クラスがインタフェースを経由してML1
クラスのF()
関数を呼び出しています。まさにStrategyパターンの図式そのままです。
Contextが
HL1
クラスに、StrategyがI
(インタフェース)に、ConcreteStrategyがML1
クラスに対応する
HL1
が直接ML1
を呼び出すのではなく、インタフェースを経由して間接的に呼び出すかたちにすることで、依存関係が制御の流れと逆になっています。これが依存関係逆転です。
すなわち、制御の流れとしては変わらずHL1
からML1
の向き(緑の点線矢印)なのですが、依存関係の向きはML1
からインタフェースI
の方向(赤の上矢印)になります。
これの何が嬉しいかと言うと、インタフェースを間に挟むことで、上位レベルのクラス(HL1
)が下位レベルのクラス(ML1
)の詳細に引っ張られなくなります。上位レベルのクラスはインタフェースに対してプログラミングすればよくなり(実際に呼び出す下位クラスの具体的な実装については知らなくよくなり)、下位クラスはインタフェースの振る舞いを全うするようにコーディングしてあげればよくなります。
それによってお互いの責務が明確になり、さらにはお互いの変更が影響し合わないため改修がしやすい構造になります。