我喜欢玩游戏,更喜欢搜集和练习游戏类的编程kata,因为这种kata会让我在练习过程中体会到十足的乐趣,前面的编程道场介绍了一个经典kata——FizzBuzz游戏,这次我们要认识另一个kata——PokerHands游戏。
背景介绍
PokerHands取材自德州扑克,52张扑克分为4个花色(用C、D、H、S表示),扑克牌面最小为2,最大为Ace,表示为2、3、4、5、6、7、8、9、T、J、Q、K、A。一轮游戏中,玩家会持有5张牌,按照规则(同花顺、四只、三带二、同花、顺子、三只、两对、一对、散牌)进行比较,其中同花顺最大散牌最小,而当遇到相同牌面值时,则比较剩余牌的大小。
有了之前FizzBuzz的经历,我们知道对这种多规则并且规则与规则间存在优先级的kata,可以采用策略模式或者责任链模式来实现,有了这一基本思路就可以开始练习了。
实现
上面提到PokerHands的练习目标是使用设计模式完成游戏规则的编码,先从优先级最低的散牌开始,散牌的规则比较容易理解,简单的说就是比大小,但别看这一句比大小,其实包含了几个概念:
- 两张牌相等如何判断
- 两张牌大小如何判断
显然我们要实现的代码需要具备这两个功能,结合牌的描述,可以设计出下面的Poker类。
public class Poker implements Comparable<Poker> {
private final Suit suit;
private final int value;
public Poker(Suit suit, int value) {
this.suit = suit;
this.value = value;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Poker poker = (Poker) o;
return value == poker.value;
}
...
@Override
public int compareTo(Poker o) {
return Integer.compare(this.value, o.value);
}
}
散牌只是在Poker的基础上实现多个Poker的比较,由于是比较一手牌中最大,所以排序就可以快速帮助我们完成散牌规则。
public class HighCard implements Rule {
@Override
public int compare(List<Poker> white, List<Poker> black) {
final List<Integer> sortedWhite = collect(white);
final List<Integer> sortedBlack = collect(black);
return IntStream.range(0, 5)
.map(i -> sortedWhite.get(i).compareTo(sortedBlack.get(i)))
.filter(c -> c != 0).findFirst().orElse(0);
}
@Override
public List<Integer> collect(List<Poker> pokers) {
return pokers.stream().map(Poker::getValue)
.sorted(Comparator.reverseOrder())
.collect(Collectors.toList());
}
}
一对与两对规则非常相似,都是从一手牌中找出重复并比较重复牌的大小,因此可以引入模板来实现对相同功能的编制,唯一需要区分的是对子的个数。
public abstract class Pair implements Rule {
@Override
public int compare(List<Poker> white, List<Poker> black) {
return compareWithPair(white, black, getPairCount());
}
protected abstract int getPairCount();
private int compareWithPair(List<Poker> white, List<Poker> black, int pairCount) {
final List<Integer> pairWhite = collect(white);
final List<Integer> pairBlack = collect(black);
final int pairWhiteSize = pairWhite.size();
final int pairBlackSize = pairBlack.size();
if (pairWhite.size() < pairCount && pairBlackSize < pairCount)
return 0;
if (pairWhiteSize == pairCount && pairBlackSize < pairCount)
return 1;
if (pairWhiteSize < pairCount && pairBlackSize == pairCount)
return -1;
return IntStream.range(0, pairCount)
.map(i -> pairWhite.get(i).compareTo(pairBlack.get(i)))
.filter(c -> c != 0).findFirst().orElse(0);
}
@Override
public List<Integer> collect(List<Poker> pokers) {
Set<Integer> set = new HashSet<>();
return pokers.stream().map(Poker::getValue).filter(v -> !set.add(v))
.sorted(Comparator.reverseOrder()).collect(Collectors.toList());
}
}
沿这个思路继续,很快地就能得到三只、顺子和同花的规则代码,加上前面的散牌和对子,便可以组合和推导出四只、三拖二以及同花顺,例如三拖二就是三只和对子的组合。
public class FullHouse implements Rule {
private ThreeofaKind three = new ThreeofaKind();
private OnePair pair = new OnePair();
@Override
public int compare(List<Poker> white, List<Poker> black) {
final List<Integer> whiteFullHouse = collect(white);
final List<Integer> blackFullHouse = collect(black);
final int whiteSize = whiteFullHouse.size();
final int blackSize = blackFullHouse.size();
if (whiteSize < 2 && blackSize < 2)
return 0;
if (whiteSize == 2 && blackSize < 2)
return 1;
if (whiteSize < 2 && blackSize == 2)
return -1;
return three.compare(white, black);
}
@Override
public List<Integer> collect(List<Poker> pokers) {
final List<Integer> threePokers = three.collect(pokers);
final List<Integer> pairPokers = pair.collect(pokers);
threePokers.addAll(pairPokers);
return threePokers;
}
}
思考
在规则实现的过程中,尽管使用了设计模式,但是仍然会有不少看上去十分相似的代码,如何去消除这些重复呢?
另外每增加一个规则就会增加一个类,在规则较少时这些类看上去非常规整和漂亮,但当达到一定数量时,如此多的类就会让代码结构看上去非常“庞大”,有没有什么办法能够控制一下类的数量呢?
要回答这两个问题,需要重新考察分析一下游戏中的规则,将规则分组归类之后得到下面的表达式,这些表达式用于描述玩家手中的牌可以归属哪种规则。从表达式可以看到不少规则是在计算值出现频率,例如两对按这个表达式的解读是出现2组值出现2次的牌,而三只就是出现1组值出现3次的牌;此外还有计算花色和牌面差值的规则,唯一有点特殊的是散牌,似乎是其他规则的反例,但实际上散牌可以理解为5组值出现1次的牌。
high card -> high card
pair -> 1*(值出现*2)
two pair -> 2*(值出现*2)
three of a kind -> 1*(值出现*3)
flush -> 花色相同
straight -> 值间差值=1
four of a kind -> 1*(值出现*4)
full house -> 1*(值出现*3)+1*(值出现*2)
straight flush -> 花色相同+值间差值=1
经过上面的分析,规则对于牌面的描述可以总结为三条规则:
- 差值,即是否满足等差的牌面
- 频率,即是否存在按指定次数分布的牌面
- 花色,即是否花色一致
@FunctionalInterface
public interface Matcher {
boolean match(List<Poker> pokers);
static Matcher duplicate(int frequency, int groupCount) {
return pokers -> {
final HashSet<Poker> set = new HashSet<>(pokers);
return set.stream().filter(p -> Collections.frequency(pokers, p) == frequency)
.collect(Collectors.toList()).size() == groupCount;
};
}
static Matcher dvalue(int value) {
return pokers -> {
final List<Poker> orderPokers = pokers.stream().sorted(Comparator.reverseOrder()).collect(Collectors.toList());
return orderPokers.get(0).getValue() - orderPokers.get(orderPokers.size() - 1).getValue() == value;
};
}
static Matcher samesuit() {
return pokers -> {
final HashSet<Suit> set = new HashSet<>(pokers.stream().map(Poker::getSuit).collect(Collectors.toList()));
return set.size() == 1;
};
}
static Matcher select(Matcher... matchers) {
return pokers -> Arrays.stream(matchers).allMatch(m -> m.match(pokers));
}
}
不同的牌面描述对应了不同特征牌搜集方式和比较方式,归纳特征牌的搜集和比较方法,得到下面的表达式。
high card -> 排序
pair -> 对子值 + 余牌排序
two pair -> 2组对子值(排序) + 余牌排序
three of a kind -> 3只值 + 余牌排序
flush -> 排序
straight -> 排序
four of a kind -> 4只值 + 余牌排序
full house -> 3只值 + 对子值
straight flush -> 排序
可见牌面搜集主要依靠排序和特征值提取(按频率提取),而在牌面进行比较时,实际上是一种散牌比较的变形。散牌比较排序后按从大到小进行比较,而非散牌的牌面比较也需要排序,特征值排序结合余牌排序,而特征值又始终位于余牌之前,是一种逻辑上的大值,例如:牌面2H 5S 9C 2D 7H,可以简化为2 9 7 5,这就是一种特征值+余牌排序的表达。
@FunctionalInterface
public interface Assemble {
List<Integer> collect(List<Poker> pokers);
static Assemble sortmap() {
return pokers -> pokers.stream().map(Poker::getValue)
.sorted(Comparator.reverseOrder()).collect(Collectors.toList());
}
static Assemble dupmap(int times) {
return pokers -> {
HashSet<Poker> set = new HashSet<>(pokers);
return set.stream().filter(p -> Collections.frequency(pokers, p) == times)
.map(Poker::getValue).sorted(Comparator.reverseOrder()).collect(Collectors.toList());
};
}
static Assemble rest(Assemble assemble) {
return pokers -> {
final List<Integer> list = assemble.collect(pokers);
return pokers.stream().map(Poker::getValue).filter(v->!list.contains(v))
.sorted(Comparator.reverseOrder()).collect(Collectors.toList());
};
}
static Assemble combine(Assemble a1, Assemble a2) {
return pokers -> {
final List<Integer> l1 = a1.collect(pokers);
final List<Integer> l2 = a2.collect(pokers);
l1.addAll(l2);
return l1;
};
}
}
按照上面的分析和推导,最终形成的规则样式如下,它通过一组牌面描述和牌面搜集描述来刻画一条规则。
with(select(dvalue(4), duplicate(1, 5), samesuit()), to(STRAIGHT_FLUSH.getType(), sortmap())),
with(duplicate(4, 1), to(FOUR_OF_A_KIND.getType(), dupmap(4))),
with(select(duplicate(3, 1), duplicate(2, 1)), to(FULL_HOUSE.getType(), combine(dupmap(3), dupmap(2)))),
with(samesuit(), to(FLUSH.getType(), sortmap())),
with(select(dvalue(4), duplicate(1, 1)), to(STRAIGHT.getType(), sortmap())),
with(duplicate(3, 1), to(THREE_OF_A_KIND.getType(), combine(dupmap(3), rest(dupmap(3))))),
with(duplicate(2, 2), to(TWO_PAIRS.getType(), combine(dupmap(2), rest(dupmap(2))))),
with(duplicate(2, 1), to(PAIR.getType(), combine(dupmap(2), rest(dupmap(2))))),
with(duplicate(1, 5), to(HIGH_CARD.getType(), sortmap()))
显然在经历了这一番的变化之后,代码的结构发生的变化,原来看似重复的代码变成了可任意组合的小块,原来庞大的规则类也化成了一条条更加直观的规则描述。
结语
PokerHands的好玩之处在于你明知道它的套路,却仍然能够在练习的过程中发掘出更多有意思的东西,这便是kata的魅力所在,你永远能够从中获取自己的kata感悟。
同时上面这些变化和思考并不是一次修订带来,而是在写代码的过程中逐步思考得到,边练边想边调整,不断地重构我们的代码设计,这就是kata的精妙所在,它总是精心设计的,以期触发更深的思考。