编程kata,抽丝剥茧PokerHands

我喜欢玩游戏,更喜欢搜集和练习游戏类的编程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的精妙所在,它总是精心设计的,以期触发更深的思考。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,937评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,503评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,712评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,668评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,677评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,601评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,975评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,637评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,881评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,621评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,710评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,387评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,971评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,947评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,189评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,805评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,449评论 2 342

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,413评论 25 707
  • 100多年前, 马克·吐温回首自己的人生, 写下这样一段话, 那是对我们最好的启示: “时光荏苒,生命短暂, 别将...
    大智者许若愚阅读 339评论 0 0
  • 一程山路一重山, 一段风雨一段阳。 山路难走千山过, ...
    阿准先生阅读 254评论 0 2
  • 星期一的早晨,韩玲玲会照常去教学B楼买一份21世纪报。通常她嘴里吸着满大杯的豆浆,面色被太阳晒得如西班牙斗牛士的大...
    July鲸鱼阅读 649评论 1 8
  • 什么是微服务 微服务就是一些协同工作的小而自治的服务。 微服务主要好处 技术异构性:在一个由多个服务相互协作的系统...
    TXN阅读 1,653评论 0 0