View on GitHub

软件开发与教练

blog

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


这是我和张云雷在群里进行code review和重构的练习过程。 ####题目 Count Coins。大致是这样的,有1分,5分,10分和25分四种硬币。给定一个金额,算出有多少种组合方式表示这个金额。 比如:

读到这里,你是不是已经对怎么解决这个问题有些设想了? 实话说我还没有很仔细的想清楚这道题。张云雷在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感想 因为我并没有很清晰的思路,对问题也是一知半解。所以第一眼看上去必然是懵圈的。下面是些直观感受:

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

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

当然先加测试咯。

张云雷是用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条件一个名字

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

#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这个函数进行的,基于以下几点考虑

名副其实

最终代码如下:

#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给了我们另一个理解代码的角度。这大概就是结对的意义所在吧。