远程异步结对 - Count Coins

警告:本文包括大段代码,读到代码会感到不适的同学请绕行。


这是我和张云雷在群里进行code review和重构的练习过程。

题目

Count Coins。大致是这样的,有1分,5分,10分和25分四种硬币。给定一个金额,算出有多少种组合方式表示这个金额。
比如:

  • 3分钱只有1种组合方式,3枚分币;
  • 5分钱有2种组合,5枚一分硬币,或,1枚五分硬币;
  • 10分钱有4种组合,10枚一分,5枚一分加1枚五分,2枚五分,1枚10分;

读到这里,你是不是已经对怎么解决这个问题有些设想了?
实话说我还没有很仔细的想清楚这道题。张云雷在Cyber Dojo上做了练习,把代码贴在TDD群里求Review和重构。

重构前代码

#include "CountCoins.hpp"
enum class CoinsType:int{
    ZERO = 0,
    PENNY=1,
    NICKEL=5,
    DIME=10,
    QUARTER=25
} ;
 
int operator /(int value, CoinsType type)
{
    return value / static_cast<int>(type);
}
 
int operator *(int value, CoinsType type)
{
    return value * static_cast<int>(type);
}
 
int operator -(int value, CoinsType type)
{
    return value - static_cast<int>(type);
}
 
CoinsType operator -- (CoinsType type, int)
{
    switch (type){
        case CoinsType::QUARTER:
            return CoinsType::DIME;
        case CoinsType::DIME:
            return CoinsType::NICKEL;
        case CoinsType::NICKEL:
            return CoinsType::PENNY;
        case CoinsType::PENNY:
            return CoinsType::ZERO;
        default:
            return CoinsType::PENNY;
    }
}
 
int makeChangesWithoutCurrentType(int totalCents, CoinsType type)
{
    return makeChangesWith(totalCents, type--);
}
int makeChangesWithCurrentType(int totalCents, CoinsType type)
{
    int ways = 0;
    for( int i = 1; i <= totalCents / type; i++)
    {
        int smallerNorminalCents = totalCents - i * type;  
        ways += makeChangesWith(smallerNorminalCents, type--);
    }
    return ways;
}
int makeChangesWith(int totalCents, CoinsType type)
{
    if( type == CoinsType::PENNY)
    {
        return 1;
    }
    int ways = 0;
    ways += makeChangesWithCurrentType(totalCents, type);
    
    ways += makeChangesWith(totalCents, type--);
    return ways;
}
int makeChange(int totalCents)
{
    if( totalCents >= 1 )
    {
        return makeChangesWith(totalCents, CoinsType::QUARTER);
    }
    return 0;
}

不知道你读这段代码的感受如何,先说说我读到的感觉吧。

我的review感想

因为我并没有很清晰的思路,对问题也是一知半解。所以第一眼看上去必然是懵圈的。下面是些直观感受:

  • 有点长。一屏看不完就不禁让人产生压力,有种无法一手掌握的感觉。
  • 当然,有可能问题本身就比较复杂,必须要相当篇幅的代码。不过这样想的话,压力就更大了。
  • 仔细看看,其实相当一部分行数是又单独一行的大括号贡献的。不是很熟悉C++,对此就不评论了。
  • 可以感觉到张云雷在写这段代码时尽量使它可读的努力。比如函数名字都比较长。函数都很短小。
  • 一开始的领域对象非常有效,我甚至在不知道题目的时候,就已经大致知道这段程序是和不同金额的硬币相关的。
  • 虽然已经尽力起了有意义的名称,但是名字太像了。
    makeChangesWithoutCurrentTypemakeChangesWithCurrentTypemakeChangesWithmakeChangesWith
    总之我是晕了。
  • 因为测试没有一起贴出来,造成另一个问题。不知道这几个函数谁是入口,这段代码是怎么使用的。

把我的这些不成熟意见说给张云雷后,还好他并没有对我的理解能力提出质疑,而是颇有同感的说他自己也对命名不满意,只是一时没想到怎么更好的表达。并请我重构一下试试。

但是,自己都不是很明白的问题,看起来有点复杂的代码,其实并不会用的语言。
怎么重构呢?

当然先加测试咯。

张云雷是用TDD写出这段代码的,当然是可以要到测试的。不过转念一想,其实这个情况和现实工作中接手代码有点像,并不一定完全理解,也不一定有测试。所以我决定自己先把测试补出来,一来理解问题和程序的行为,二来为后面的重构保驾护航。

重构过程

Cyber Dojo用的多了以后,我发现仅仅观察每次执行测试后红绿灯的记录,就能发现某些模式,代表了逐步掌握代码的过程。
比如我重构这段代码的记录如下:


重构红绿灯
  1. 首先是让代码跑起来的过程。可以看到有较多的黄灯和红灯,标志着一开始的尝试。这时候很容易出现一些低级错误比如缺失头文件,文件命名错误等等。
    这道题目是个单纯的练习,这个阶段很快就度过了。而对于真实的遗留代码,也许会有相当长的黄灯红灯循环,仅仅是为了能new出一个可以测试的目标类。
  2. 接下来是通过测试学习程序现有行为的过程。基本上就是写一个失败的测试,然后根据错误信息把实际的输出填写在测试期待值里。
    由于这段程序的输入输出单纯,所以我偷懒一次写出一串数字,然后一个一个的改变期待值。如果真的一板一眼的做,这里应该是严格的红灯->绿灯->红灯->绿灯循环。
  3. 有了测试保证后,下一个阶段就正式进入修改代码重构的阶段。如果注意方法,步子够小的话。会产生一长串绿灯,间或有一两个黄灯或红灯。比如我这样。_
  4. 之后是比较有趣的部分。相当长一段时间的绿灯后。好像会觉得有些无聊。这时随着自信心的增长,会开始一些新的尝试,比如不熟悉的语言特性,或者改变基本的程序流程来达到更加可读或更简洁的目的。
    这时有可能因为尝试,产生连续的黄灯红灯。而一旦突破了想要实现的转变,后续又会是一长串的绿灯。

补出测试

托Cyber Dojo的福,不用做我最头疼的搭建环境工作,直接建立个C++的练习即可。
赫然发现原来有两种不同的C++可选,果然水很深。胡乱选一个,但愿对这段代码没区别。
然后是测试框架,也有好几个。还是胡乱选了第一个Boost。
把代码贴进去,改了几个文件命名错误,然后就一句一句贴出了如下的测试代码。

BOOST_AUTO_TEST_CASE(Count_Coins)
{
    BOOST_REQUIRE_EQUAL(0, makeChange(0));
    BOOST_REQUIRE_EQUAL(1, makeChange(1));

    BOOST_REQUIRE_EQUAL(2, makeChange(5));
    BOOST_REQUIRE_EQUAL(2, makeChange(7));

    BOOST_REQUIRE_EQUAL(4, makeChange(10));
    BOOST_REQUIRE_EQUAL(4, makeChange(13));

    BOOST_REQUIRE_EQUAL(6, makeChange(15));
    BOOST_REQUIRE_EQUAL(6, makeChange(18));

    BOOST_REQUIRE_EQUAL(9, makeChange(20));
    BOOST_REQUIRE_EQUAL(9, makeChange(22));

    BOOST_REQUIRE_EQUAL(13, makeChange(25));
    BOOST_REQUIRE_EQUAL(13, makeChange(29));

    BOOST_REQUIRE_EQUAL(18, makeChange(30));
    BOOST_REQUIRE_EQUAL(24, makeChange(35));
    BOOST_REQUIRE_EQUAL(31, makeChange(40));
    BOOST_REQUIRE_EQUAL(39, makeChange(45));
    BOOST_REQUIRE_EQUAL(49, makeChange(50));
    BOOST_REQUIRE_EQUAL(60, makeChange(55));
    BOOST_REQUIRE_EQUAL(73, makeChange(60));
}

这里是对应于那一长串的红灯。
由于偷懒,写成了在一个case里罗列一长串assert的样子,实在不是一种很好的风格。不过对于重构保护已经足够了。
就算这么无脑的尝试,也给了我一些启发。比如我发现每5分钱一个档次,当金额增长没有超过5时,输出是一样的。而每次增长了一档,就会多出好多个组合,而且随着金额越来越大,增幅也越来越大。

第一版重构

主要是从以下角度对代码进行了改变。

  • 尽一步凸显代码的意图,比如


    把函数名字改成它实际做的事情
给if条件一个名字
  • 修改了易于混淆的命名。把makeChange这个动作留给发起动作的入口函数,而把分别计算不同情况的函数命名为changeWaysWithxxx。
  • 意外的发现有些没用的代码,愉快的删掉了。

这时相当于红绿灯图里第一个长串绿灯结束的位置。代码变成了这样

#include "countCoins.hpp"
enum class CoinsType:int{
    PENNY=1,
    NICKEL=5,
    DIME=10,
    QUARTER=25
} ;
 
int operator *(int value, CoinsType type)
{
    return value * static_cast<int>(type);
}
 
CoinsType operator -- (CoinsType type, int)
{
    switch (type){
        case CoinsType::QUARTER:
            return CoinsType::DIME;
        case CoinsType::DIME:
            return CoinsType::NICKEL;
        case CoinsType::NICKEL:
            return CoinsType::PENNY;
        default:
            return CoinsType::PENNY;
    }
}
 
const CoinsType BIGGEST_COIN = CoinsType::QUARTER;
bool noSmallerCoin(CoinsType type) {
    return type == CoinsType::PENNY;
}
 
int makeChange(int cents)
{
    if( cents == 0 ) {
        return 0;
    }
    return changeWaysWithCoinOrSmaller(cents, BIGGEST_COIN);
}
 
int changeWaysWithCoinOrSmaller(int cents, CoinsType type)
{
    if( noSmallerCoin(type) )
    {
        return 1;
    }
    int ways = 0;
    ways += changeWaysWithCoin(cents, type);    
    ways += changeWaysWithCoinOrSmaller(cents, type--);
    return ways;
}
 
int changeWaysWithCoin(int cents, CoinsType type)
{
    int ways = 0;
    for( int i = 1; i * type <= cents ; i++)
    {
        int remaining = cents - i * type;  
        ways += changeWaysWithCoinOrSmaller(remaining, type--);
    }
    return ways;
}

有没有好一点?
好吧也许看起来变化不大,但是至少短了一点吧。

第二版重构

由于第一版重构后感觉不过瘾,之后又进行了第二版重构。
这次重构主要是围绕着changeWaysWithCoin这个函数进行的,基于以下几点考虑

  • 本来仅仅是为了取巧,把循环条件里判断金额除以硬币面额,和循环体内每次取出若干个硬币金额的代码。统一成了i乘以面额。这样可以少定义一个除法操作。
    结果意外的发现,其实这个循环相当于每次去掉一枚硬币,只和剩余金额有关。所以这段逻辑可以简化为每次取出一枚硬币面额直到剩余的金额小于面额为止。不需要一个循环次数作为噪音。
  • 进一步思索,发现这种循环可以改变成递归。由于程序的总体流程是递归,所以一个局部的循环实际上在代码中增加了行为范式,增加了复杂度。
  • 去掉了循环之后,这个函数的职责更加清楚了。从而带来了一个更有意义的名字waysTakeOneCoinAway
    附带的效果是,原本changeWaysWithCoinOrSmaller这个名字显得十分笨拙。现在它的名字就像是代码的自然描述,显得一目了然。
名副其实

最终代码如下:

#include "countCoins.hpp"
enum class CoinsType:int{
    PENNY=1,
    NICKEL=5,
    DIME=10,
    QUARTER=25
} ;
 
int operator -(int value, CoinsType type)
{
    return value - static_cast<int>(type);
}
 
CoinsType operator -- (CoinsType type, int)
{
    switch (type){
        case CoinsType::QUARTER:
            return CoinsType::DIME;
        case CoinsType::DIME:
            return CoinsType::NICKEL;
        case CoinsType::NICKEL:
            return CoinsType::PENNY;
        default:
            return CoinsType::PENNY;
    }
}
 
const CoinsType BIGGEST_COIN = CoinsType::QUARTER;
bool noSmallerCoin(CoinsType type) {
    return type == CoinsType::PENNY;
}
 
int makeChange(int cents)
{
    return cents == 0 ? 0 : changeWaysWithCoinOrSmaller(cents, BIGGEST_COIN);
}
 
int waysTakeOneCoinAway(int cents, CoinsType coin)
{
    int afterTaken = cents - coin;
    return afterTaken < 0 ? 0 : afterTaken == 0 ? 1 :
            changeWaysWithCoinOrSmaller(afterTaken, coin);
}
 
int waysWithSmallerCoin(int cents, CoinsType coin)
{
    return noSmallerCoin(coin) ? 0 : changeWaysWithCoinOrSmaller(cents, coin--);
}
 
int changeWaysWithCoinOrSmaller(int cents, CoinsType coin)
{
    return waysWithSmallerCoin(cents, coin) + waysTakeOneCoinAway(cents, coin);
}

整个过程在
http://cyber-dojo.org/kata/edit/DBEB923C89?avatar=tuna

心得

这不是第一次在群里与张云雷交换对代码的看法了。
我发现一个有趣的现象,有几次看云雷重构我的代码,对函数的重命名让我十分惊艳。我还感叹云雷特别擅长起名字。
结果这次反过来了,他看完我的重命名后说这就是他想表达的意思,只是写的时候找不到合适的表达方式。
看来也许并不完全是能力问题,重构和review给了我们另一个理解代码的角度。这大概就是结对的意义所在吧。

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

推荐阅读更多精彩内容

  • 做TDD是为什么? 关于TDD的概念、工具、技巧等,经典的书籍材料可能介绍的更为全面细致。这篇文章想分享的是从一个...
    武可阅读 2,560评论 2 21
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 170,571评论 25 707
  • 《重构》读书笔记 总览 第一部分 第一章从实例程序出发,展示设计的缺陷,对其重构可以了解重构的过程和方法。 第二部...
    白桦叶阅读 2,311评论 2 5
  • 爱的力量 15春高铁 周金
    读点卡思阅读 211评论 0 1
  • 知道了失望越大,希望越大!长得好跌得重。没人愿意这样,只是你自己想这样,傻成这样!懂?好好的,浪费了。你应该是知道...
    DeathKnightR阅读 128评论 0 0