增量设计
前言
使用 TDD 的主要原因是,能够以可承受的、稳定的维护成本来添加或修改功能特性。在本章中,你将学到重构过程中需要做的事情。我们将主要讨论 Kent Beck 提出的简单设计理念(参见《解析极限编程:拥抱变化》),以及可以保持代码整洁的一系列重要规则。
简单设计
- 在使用 TDD 时需要考虑三条规则:
- 确保代码具备很强的可读性和表达力。
- 在和第一条规则不冲突的情况下,消除所有的重复。
- 不要向系统引入不必要的复杂性。避免猜测行的结构关系和不能增加系统表达力的抽象。
重复代码的代价
随着时间的推移,重复代码或许是维护代码库的最大开销。大多数开发者懒于创建新的成员函数,因为怀疑这会降低性能,所以他们有时甚至会拒绝这样做。最终,他们只是为自己创造了更多的未来工作量。
由于对重复的自然倾向,大部分大型系统的代码远远多于实际需要的代码。这些额外的代码大大地增加了维护成本和风险。
将增量重构作为 TDD 环节的一部分可以避免系统级的退化。
投资管理器
以开发一个小型子系统举例:
- 场景:投资管理器
- 投资人想要跟踪股票买卖记录,并将此作为金融分析的基础。
本篇示例代码大部分编写过程不在详细赘述,如果需要可以在源码工程中查找到。
经过初步编写,获得了如下的测试代码:
// test/Catch2/PortfolioTest/PortfolioTest.cpp
#include "Portfolio/Portfolio.h"
#include "catch2/catch.hpp"
using namespace Catch;
TEST_CASE("Create portfolio example")
{
Portfolio portfolio;
}
class APortfolio
{
public:
Portfolio m_portfolio;
};
TEST_CASE_METHOD(APortfolio, "Is empty whe created", "[Portfolio]")
{
REQUIRE(m_portfolio.isEmpty());
}
TEST_CASE_METHOD(APortfolio, "Is not empty after purchase", "[Portfolio]")
{
m_portfolio.purchase("IBM", 1);
REQUIRE_FALSE(m_portfolio.isEmpty());
}
TEST_CASE_METHOD(APortfolio, "Answers zero for share count of no purchased symbol", "[Portfolio]")
{
REQUIRE(m_portfolio.shareCount("AAPL") == 0u);
}
TEST_CASE_METHOD(APortfolio, "Answers share count for purchased symbol", "[Portfolio]")
{
m_portfolio.purchase("IBM", 2);
REQUIRE(m_portfolio.shareCount("IBM") == 2u);
}
//Src/Portfolio/Portfolio.h
#pragma once
#include <string>
class Portfolio
{
public:
Portfolio();
bool isEmpty() const;
void purchase(const std::string& Symbol, unsigned int ShareCount);
unsigned int shareCount(const std::string& Symbol) const;
private:
bool m_isEmpty;
unsigned int m_shareCount;
};
//Src/Portfolio/Portfolio.cpp
#include "Portfolio.h"
using namespace std;
Portfolio::Portfolio()
: m_isEmpty { true }
, m_shareCount { 0u }
{
}
bool Portfolio::isEmpty() const
{
return m_isEmpty;
}
void Portfolio::purchase(const string& Symbol, unsigned int ShareCount)
{
m_isEmpty = false;
m_shareCount = ShareCount;
}
unsigned int Portfolio::shareCount(const string& Symbol) const
{
return m_shareCount;
}
测试代码就是阅读代码首要的理解途径。
投资管理器中的简单重复
参考代码版本:Git SHA (18fdb0404fcec58c117f776cadb40d4909c50243);
首先要知道一点,不仅仅是生产代码,测试代码中避免重复也是很重要的。例如字符串 “IBM” 在两个测试中就重复了 3 次。
class APortfolio
{
public:
static const string IBM;
Portfolio m_portfolio;
};
const string APortfolio::IBM("IBM");
使用 IBM 成员替换掉 “IBM” 字符串。
如下代码中也存在重复:
#include "Portfolio.h"
using namespace std;
Portfolio::Portfolio()
: m_isEmpty { true }
, m_shareCount { 0u }
{
}
bool Portfolio::isEmpty() const
{
return m_isEmpty;
}
void Portfolio::purchase(const string& Symbol, unsigned int ShareCount)
{
m_isEmpty = false;
m_shareCount = ShareCount;
}
unsigned int Portfolio::shareCount(const string& Symbol) const
{
return m_shareCount;
}
从视觉角度来看,代码并没有明显的行与行(或表达式与表达式)的重复。但其中存在算法级的重复。成员函数 IsEmpty() 返回一个布尔量,这个布尔量会在 Purchase() 被调用时更改。但是,空的概念却直接绑定了股票数目,股票数目会在调用 Purchase() 时被赋值。通过去除 m_isEmpty 变量,让 IsEmpty() 查询股票的数量,我们可以消除这一概念重复。
#include "Portfolio.h"
using namespace std;
Portfolio::Portfolio()
: m_shareCount { 0u }
{
}
bool Portfolio::isEmpty() const
{
return 0 == m_shareCount;
}
void Portfolio::purchase(const string& Symbol, unsigned int ShareCount)
{
m_shareCount = ShareCount;
}
unsigned int Portfolio::shareCount(const string& Symbol) const
{
return m_shareCount;
}
通过查询股票数量来决定是不是空有些啰嗦,但是目前是正确的。而且随证增量编写代码,往往会有一些更有趣的想发进而促进新测试的产生。例如,有人购买了 0 股某股票,Portfolio::isEmpty() 会返回空。但是我们对于空的定义是是否包含任何股票,所以这是否为空?或者不应该允许这笔买入?确定好前进的方向,并增加一个测试吧。
算法的重复(解决同一问题的不同方法或问题的不同部分)会随系统增长演变为重大问题。通常来说,随着对一个实现的改动未能编写进其他实现,重复代码会演化为不经意的变体。
如何坚持增量的方法
参考代码版本:Git SHA (76f547915f27e85a2078d92c70eac8144da2e336);
经过一段时间的编码后,我们得到了如下的一些测试:
TEST_CASE_METHOD(APortfolio, "Is empty whe created", "[Portfolio]")
TEST_CASE_METHOD(APortfolio, "Is not empty after purchase", "[Portfolio]")
TEST_CASE_METHOD(APortfolio, "Answers zero for share count of no purchased symbol", "[Portfolio]")
TEST_CASE_METHOD(APortfolio, "Answers share count for purchased symbol", "[Portfolio]")
TEST_CASE_METHOD(APortfolio, "Throw on purchase of zero shares", "[Portfolio]")
TEST_CASE_METHOD(APortfolio, "Answers share count for appropriate symbol", "[Portfolio]")
TEST_CASE_METHOD(APortfolio, "share count reflects accumulated for purchases same symbol", "[Portfolio]")
TEST_CASE_METHOD(APortfolio, "Reduces share count of symbol on sell", "[Portfolio]")
TEST_CASE_METHOD(APortfolio, "Throw when selling more shares than purchased", "[Portfolio]")
新增的需求来了:
- 场景:显示买入历史记录
- 投资者想看一下特定股票的购买记录,每个记录要显示购买的日期及数量。
就当前实现来说,这个场景开发很困难,因为我们没有跟踪每一笔的交易,更没有记录购买日期。这也是许多开发者对 TDD 质疑的地方。但是如果多花一些时间进行前期的需求分析,那么我们就会知道需要跟踪买日日期,这样在最初的设计就可能纳入这个需求。
但现在已经开发一般,马后炮是没有用的。这个需求场景需要重构,设定好 10 分钟的闹钟,开始。
参考代码版本:Git SHA (d4516419538f4adfd5d8f252c10a9e8f37b528ec)
- 先理顺以下需要做的工作:
- 首先必须定义好表示买入的数据结构、改变方法的参数列表、从客户端代码提供日期、正确地填写数据结构并储存数据。
- 不过先不要开始,至少不要一口气做完。时刻谨记是否可以增量的进行,每几分钟就寻求一下正面的反馈。例如,让我们先创建一个做出一笔买入的测试,然后验证相应的买入是否在购买记录中。假设买入总是在一个指定的日期做出,因为可以不给 Purchase() 传递日期,这使得目前的任务更简单。
TEST_CASE_METHOD(APortfolio, "Answers the purchase record for a single purchase")
{
using boost::gregorian::date;
m_portfolio.purchase(SAMSUNG, 5);
auto purchases = m_portfolio.purchases(SAMSUNG);
auto purchase = purchases[0];
REQUIRE(purchase.m_shareCount == 5u);
REQUIRE(purchase.m_date == Portfolio::FIXED_PURCHASE_DATE);
}
为了使测试通过,可以先不需要将买入记录于 holding 数据结构关联。因为目前的假设只考虑单次买入,所以可以定义一个“全局的”买入记录集合:
struct PurchaseRecord
{
PurchaseRecord(unsigned int ShareCount, const boost::gregorian::date& Date)
: m_shareCount(ShareCount)
, m_date(Date)
{
}
unsigned int m_shareCount;
boost::gregorian::date m_date;
};
class Portfolio
{
public:
static const boost::gregorian::date FIXED_PURCHASE_DATE;
bool isEmpty() const;
void purchase(const std::string& Symbol, unsigned int ShareCount);
void sell(const std::string& Symbol, unsigned int ShareCount);
unsigned int shareCount(const std::string& Symbol) const;
std::vector<PurchaseRecord> purchases(const std::string& Symbol) const;
private:
std::unordered_map<std::string, unsigned int> m_holdings;
std::vector<PurchaseRecord> m_purchases;
};
参考代码版本:Git SHA (34253c6cd9326d82aa508e8e82bd52061b26508f)
在继续之前,获得对敲入代码的正面反馈是一件很好的事情。我们定义了一个常量 FIXED_PURCHASE_DATE,以便取得快速的、可以展示的进步。我们知道这是假设的,现在去掉这个假设,然后增加一个新的测试。
TEST_CASE_METHOD(APortfolio, "Answers the purchase record for a single purchase")
{
using boost::gregorian::date;
date dateOfPurchase(2021, boost::date_time::Mar, 17);
m_portfolio.purchase(SAMSUNG, 5, dateOfPurchase);
auto purchases = m_portfolio.purchases(SAMSUNG);
auto purchase = purchases[0];
REQUIRE(purchase.m_shareCount == 5u);
REQUIRE(purchase.m_date == dateOfPurchase);
}
修改 purchase 接口支持传入日期参数:
//Src/Portfolio/Portfolio.h
void purchase(const std::string& Symbol, unsigned int ShareCount,
const boost::gregorian::date& TransactionDate = Portfolio::FIXED_PURCHASE_DATE);
//Src/Portfolio/Portfolio.cpp
void Portfolio::purchase(const string& Symbol, unsigned int ShareCount, const date& TransactionDate)
{
if (0 == ShareCount) throw InvalidPurchaseException();
m_holdings[Symbol] = ShareCount + shareCount(Symbol);
m_purchases.push_back(PurchaseRecord(ShareCount, TransactionDate));
}
可以看到接口使用了默认参数,但是这个参数并没有什么作用,还容易引起错误,现在想要去除 purchase 的默认时间但是还有许多测试没有传入日期,可以提供一个 fixture 辅助方法,由它来处理对 Purchase() 的调用并提供一个默认日期。
class APortfolio
{
public:
static const boost::gregorian::date ARBITRARY_DATE;
static const string IBM;
static const string SAMSUNG;
void purchase(const string& Symbol, unsigned int ShareCount,
const date& TransactionDate = APortfolio::ARBITRARY_DATE)
{
m_portfolio.purchase(Symbol, ShareCount, ARBITRARY_DATE);
}
Portfolio m_portfolio;
};
....
TEST_CASE_METHOD(APortfolio, "Reduces share count of symbol on sell", "[Portfolio]")
{
purchase(SAMSUNG, 30);
m_portfolio.sell(SAMSUNG, 13);
REQUIRE(m_portfolio.shareCount(SAMSUNG) == (30u - 13));
}
一个可能引起争议的点是,辅助函数 Purchase() 从测试中移除了一些信息,第一次阅读测试的人必须查看辅助函数以便了解其作用。但这是一个简单函数,没有隐藏对阅读者来说难以记忆的关键信息。因此在专门测试 Purchase() 行为时,我们应该直接调用它。
TEST_CASE_METHOD(APortfolio, "Answers share count for purchased symbol", "[Portfolio]")
{
m_portfolio.purchase(IBM, 2, ARBITRARY_DATE); //可以接受一个默认的测试时间传入
REQUIRE(m_portfolio.shareCount(IBM) == 2u);
}
更多的重复
参考代码版本:Git SHA (336879ab8d12cd3aeed071004ff61aa642c3d953);
为了支持负数的买入记录,将 ShareCount 改为有符号整数。
struct PurchaseRecord
{
PurchaseRecord(int ShareCount, const boost::gregorian::date& Date)
: m_shareCount(ShareCount)
, m_date(Date)
{
}
int m_shareCount;
boost::gregorian::date m_date;
};
实际的交易代码,其中三行看起来有些重复:
void Portfolio::purchase(const string& Symbol, unsigned int ShareCount, const date& TransactionDate)
{
if (0 == ShareCount) throw InvalidPurchaseException();
m_holdings[Symbol] = ShareCount + shareCount(Symbol);
m_purchases.push_back(PurchaseRecord(ShareCount, TransactionDate));
}
void Portfolio::sell(const std::string& Symbol, unsigned int ShareCount, const date& TransactionDate)
{
if (ShareCount > shareCount(Symbol)) throw InvalidSellException();
m_holdings[Symbol] = shareCount(Symbol) - ShareCount;
m_purchases.push_back(PurchaseRecord(-ShareCount, TransactionDate));
}
他们本质上很相似:
第一行的保护语句都是用来约束操作,即不能买入 0,和不能卖出的大于持有的数量,但是卖出统一也不应卖出 0,同时 sell() 接口中的 InvalidSellException 异常类型的名称可以具体化公用。
TEST_CASE_METHOD(APortfolio, "Throws on sell of zero shares") { REQUIRE_THROWS_AS(sell(IBM, 0), ShareCountCannotBeZeroException); } //Src/Portfolio/Portfolio.cpp void Portfolio::sell(const std::string& Symbol, unsigned int ShareCount, const date& TransactionDate) { if (ShareCount > shareCount(Symbol)) throw InvalidSellException(); //这样 sell 与 purchase 就有相同的判断条件了 if (0 == ShareCount) throw ShareCountCannotBeZeroException(); m_holdings[Symbol] = shareCount(Symbol) - ShareCount; m_purchases.push_back(PurchaseRecord(-ShareCount, TransactionDate)); }
通过加或减已持有的股票数,可以更新相应股票名称的持有量。这样 sell 与 purchase 公用的语句可以抽出:
void Portfolio::purchase(const string& Symbol, unsigned int ShareCount, const date& TransactionDate)
{
transact(Symbol, ShareCount, TransactionDate);
}
void Portfolio::sell(const std::string& Symbol, unsigned int ShareCount, const date& TransactionDate)
{
if (ShareCount > shareCount(Symbol)) throw InvalidSellException();
transact(Symbol, -ShareCount, TransactionDate);
}
void Portfolio::transact(
const std::string& Symbol, int ShareChange, const boost::gregorian::date& TransactionDate)
{
if (0 == ShareChange) throw ShareCountCannotBeZeroException();
m_holdings[Symbol] = shareCount(Symbol) + ShareChange;
m_purchases.push_back(PurchaseRecord(ShareChange, TransactionDate));
}
另一个和表达力相关的事情,异常类型 InvalidSellException 的名称不是很好。我们将其改成 InsufficientSharesException。
小方法的好处
Transact() 函数只包含三行简单的代码,但是想要了解整个系统确有些困难,不具备表达力对此进行修改。
void Portfolio::transact(
const std::string& Symbol, int ShareChange, const boost::gregorian::date& TransactionDate)
{
throwIfShareCountIsZero(ShareChange);
updateShareCount(Symbol, ShareChange);
addPurchaseRecord(ShareChange, TransactionDate);
}
void Portfolio::throwIfShareCountIsZero(int ShareChange) const
{
if (0 == ShareChange) throw ShareCountCannotBeZeroException();
}
void Portfolio::updateShareCount(const string& Symbol, int ShareChange)
{
m_holdings[Symbol] = shareCount(Symbol) + ShareChange;
}
void Portfolio::addPurchaseRecord(int ShareChange, const date& Date)
{
m_purchases.push_back(PurchaseRecord(ShareChange, Date));
}
下面可能是你会提出不这么做的一些理由:
- 这是额外功。创建新函数很费力。
- 为只在一个地方用到的单行代码创建一个函数似乎很荒唐。
- 额外的函数调用会加重性能开销。
- 很难在所有代码中遵循完整的控制流。
- 你会得到成千上万个小方法,且每个方法都有着巨长的名称。
期望这么做的原因:
- 这遵守了内聚和单一责任的设计原则。同一函数中的所有代码处于同一层次的抽象。修改每个函数的原因只有一个。
- 这为以后的设计改动铺平了道路。我们仍然需要将买入记录和对应的股票名称联系起来,但现在可以在一处完成改动,而非两处。
- 忽略具体的实现细节会更容易理解代码控制流。回忆一下接口和实现或抽象和具体的分离理念。
- 小方法是真正重用的开始。随着越来越多的相似函数被提取出来,识别出重复的概念和结构会变得更加容易。
完成功能
参考代码版本:Git SHA (8e75460501b0ef4c3dd3689f549eff60f1e575e4);
目前的投资管理器能够返回一个买入列表,但只能返回一支股票的列表。下一个测试要求投资管理器能返回多支已购股票的买入记录。
bool operator==(const PurchaseRecord& Lhs, const PurchaseRecord& Rhs)
{
return Lhs.m_shareCount == Rhs.m_shareCount && Lhs.m_date == Rhs.m_date;
}
// TODO: 使用新式方法实现一个 Matcher 匹配器
// https://github.com/catchorg/Catch2/blob/devel/docs/matchers.md
class ElementsAre : public Catch::MatcherBase<vector<PurchaseRecord>>
{
public:
ElementsAre(const vector<PurchaseRecord>& Param)
: m_purchaseRecord(Param)
{
}
bool match(vector<PurchaseRecord> const& Arg) const override
{
for (auto varL : Arg)
{
bool retFlag = false;
for (auto varR : m_purchaseRecord)
if (varL == varR) { retFlag = true; }
if (!retFlag) return false;
}
return true;
}
virtual std::string describe() const override { return "None Match"; }
private:
vector<PurchaseRecord> m_purchaseRecord;
};
TEST_CASE_METHOD(APortfolio, "Separates purchase records by symbol", "[Portfolio]")
{
purchase(SAMSUNG, 5, ARBITRARY_DATE);
purchase(IBM, 1, ARBITRARY_DATE);
auto sales = m_portfolio.purchases(SAMSUNG);
vector<PurchaseRecord> checkArray { PurchaseRecord(5, ARBITRARY_DATE) };
REQUIRE_THAT(sales, ElementsAre(checkArray));
}
Catch2 没有提供类似 gmock 的 ElementAre 匹配器,这里先自己实现一个,用来验证指定的元素是否在集合中。测试一开始失败了,因为名为 m_portfolio 的向量容器只包含两个买入记录——一个是 Samsung,另一个是 IBM。
现在修改代码,为每只股票保存包含了此股票买卖的记录。
class Portfolio
{
//......
void addPurchaseRecord(const std::string& Symbol, int ShareCount, const boost::gregorian::date& Date);
void throwIfShareCountIsZero(int ShareChange) const;
std::unordered_map<std::string, unsigned int> m_holdings;
std::vector<PurchaseRecord> m_purchases;
std::unordered_map<std::string, std::vector<PurchaseRecord>> m_purchaseRecords;
};
增加一个 map 用于存储交易信息,同时 addPurchaseRecord 接口增加 Symbol 参数。修改具体实现:
void Portfolio::addPurchaseRecord(const std::string& Symbol, int ShareChange, const date& Date)
{
m_purchases.push_back(PurchaseRecord(ShareChange, Date));
auto it = m_purchaseRecords.find(Symbol);
if (it == m_purchaseRecords.end()) { m_purchaseRecords[Symbol] = vector<PurchaseRecord>(); }
m_purchaseRecords[Symbol].push_back(PurchaseRecord(ShareChange, Date));
}
//......
vector<PurchaseRecord> Portfolio::purchases(const std::string& Symbol) const
{
return m_purchaseRecords.find(Symbol)->second;
}
接下来在测试列表中加入一个条目(“处理在买入记录中找不到特定股票的情况”)。同时在测试通过后可以移除掉 m_purchases 相关的代码。从重复代码角度来看,ShareCount() 和 Purchases() 中都包含了在映射容器中查找元素的代码。
//test.cpp
bool operator!=(const PurchaseRecord& Lhs, const PurchaseRecord& Rhs)
{
return !(Lhs == Rhs);
}
TEST_CASE_METHOD(APortfolio, "Answers empty purchase record vector when symbol not found", "[Portfolio]")
{
REQUIRE_THAT(m_portfolio.purchases(SAMSUNG), Equals(vector<PurchaseRecord>()));
}
//.h
class Portfolio
{
//.....
private:
template <typename T>
T mapFind(std::unordered_map<std::string, T> Map, const std::string& Key) const
{
auto it = Map.find(Key);
return it == Map.end() ? T {} : it->second;
}
std::unordered_map<std::string, unsigned int> m_holdings;
std::unordered_map<std::string, std::vector<PurchaseRecord>> m_purchaseRecords;
};
//cpp
unsigned int Portfolio::shareCount(const string& Symbol) const
{
return mapFind<unsigned int>(m_holdings, Symbol);
}
vector<PurchaseRecord> Portfolio::purchases(const std::string& Symbol) const
{
return mapFind<vector<PurchaseRecord>>(m_purchaseRecords, Symbol);
}
同时进行一点点小小优化,主要针对 addPurchaseRecord :
bool containsSymbol(const std::string& Symbol);
void initializePurchaseRecords(const std::string& Symbol);
void add(const std::string& Symbol, PurchaseRecord&& Record);
我们再一次做了大量的小函数重构。现在 AddPurchaseRecord() 声明了高层次的策略,其中的三个函数代表了策略中封装了实现细节的每个步骤。
要说明的是,我们并没有提前思考设计。相反,我们先得到了一个可以运行的代码,然后再优化现有方案的设计。这样做的副作用是,以后的改动会更加容易。
增量设计让事情变得更简单
参考代码版本:Git SHA (687b674d83cd1eb25182666c53e130e12a15881e);
在 Portfolio 中两个相似的集合:m_holdings 和 m_purchaseRecords,前者将股票和总股数相
联系,后者将股票和购买记录相联系。可以去除 m_holdings,转而按照需要来计算一个特定股票
的股数。
其实如果不去除 m_holdings 其实对性能是有益处的,但是这也会导致代码稍显复杂,这一切取决于开发人员的取舍。
bool Portfolio::isEmpty() const
{
return 0 == m_purchaseRecords.size();
}
void Portfolio::transact(
const std::string& Symbol, int ShareChange, const boost::gregorian::date& TransactionDate)
{
throwIfShareCountIsZero(ShareChange);
addPurchaseRecord(Symbol, ShareChange, TransactionDate);
}
unsigned int Portfolio::shareCount(const string& Symbol) const
{
auto records = mapFind<vector<PurchaseRecord>>(m_purchaseRecords, Symbol);
return accumulate(records.begin(), records.end(), 0,
[](int Total, PurchaseRecord Record) { return Total + Record.m_shareCount; });
}
接下来需要将所有和买入记录相关的代码抽到单独的类中,因为 Portfolio 违反了单一责任原则,修改 Portfolio 类的主要原因应该和操作股票的方式有关。但还有一个需要修改的原因——针对买入记录的特定实现细节。
通过增量的方式进行修改,首先引入一个新的成员变量,将股票和持有量联系起来:
class Portfolio
{
//.....
std::unordered_map<std::string, std::vector<PurchaseRecord>> m_purchaseRecords;
std::unordered_map<std::string, Holding> m_holdings;
};
Holding 就是后续需要将买入实现细节迁出的地方,接下来逐步将 m_holdings 替换掉 m_purchaseRecords。
void Portfolio::initializePurchaseRecords(const string& Symbol)
{
m_purchaseRecords[Symbol] = vector<PurchaseRecord>();
m_holdings[Symbol] = Holding();
}
void Portfolio::add(const string& Symbol, PurchaseRecord&& Record)
{
m_purchaseRecords[Symbol].push_back(Record);
m_holdings[Symbol].add(Record);
}
接下来将 Portfolio 中买入记录细节的代码迁出到 Holding.h 中,并将使用的 m_purchaseRecords 实现的功能替换成 Holding :
bool Portfolio::isEmpty() const
{
// return 0 == m_purchaseRecords.size();
return 0 == m_holdings.size();
}
// .....
bool Portfolio::containsSymbol(const string& Symbol)
{
// return m_purchaseRecords.find(Symbol) != m_purchaseRecords.end();
return m_holdings.find(Symbol) != m_holdings.end();
}
unsigned int Portfolio::shareCount(const string& Symbol) const
{
// auto records = mapFind<vector<PurchaseRecord>>(m_purchaseRecords, Symbol);
// return accumulate(records.begin(), records.end(), 0,
// [](int Total, PurchaseRecord Record) { return Total + Record.m_shareCount; });
return mapFind<Holding>(m_holdings, Symbol).shareCount();
}
vector<PurchaseRecord> Portfolio::purchases(const std::string& Symbol) const
{
// return mapFind<vector<PurchaseRecord>>(m_purchaseRecords, Symbol);
return mapFind<Holding>(m_holdings, Symbol).purchases();
}
最终删除 m_purchaseRecords 相关实例即可。
预先设计在哪
预先设计是一个很好的起始路线图。围绕此设计的讨论有助于发现软件中必须要做的东西,以及最初该怎样设计软件。但是,打造系统所需要的大量细节会发生变化。例如,类图是一个需要创建的好东西,但不要过度执着于底层细节:私有还是公开,属性细节,聚合还是组合,等等。这些东西来源于测试驱动的过程。相反,应致力于类名、依赖关系,或一些关键的公共行为。
TDD 允许你基于当前的业务需求,保持一个可能的最简设计。如果一直保持设计尽量简洁,那么就可以最大可能地引入新的、从未被考虑过的功能。相反,如果任由系统退化(并有大量的重复代码和晦涩难懂的代码),未来有任何新的需求时,你将痛苦万分。
哪里才会讨论真正的设计呢
当处于 TDD 的重构阶段,你要尽可能地了解与优秀设计构成相关的所有知识。同时,也要尽可能地了解团队的想法。你是在一个共享的代码库中工作,需要与团队就哪些可接受、哪些与设计无关等方面达成共识。
大多数时候,经典设计理念和简单设计原则相一致。举个例子,设计模式主要与解决方案的表达力相关。像模板方法这样的模式主要用于消除重复。
简单设计原则和经典设计理念冲突
访问性:仍然应当尽量保持成员私有化。这会使得有些改动变得更容易些。虽然不太可能,但暴露不必要的成员会使得系统受到恶意或糊涂客户的破坏。
- 但是,如果你需要放松访问控制来让测试验证一些功能是否可以如期工作,那大多数时间就不要为此担心了。如果每样东西都被测试,那么测试就会保护系统免受糊涂客户的破坏。知道系统可以如期工作远比对未来滥用的杞人忧天更让人向往。
- 在测试中,绝对要避免不必要的设计,例如,用私有还是公有控制。没有人会调用你的测试,测试中的访问指示符只会影响可读性。
及时性:老派的设计希望你能尝试获得尽可能完美的设计。在简单设计中,这是不正确的。实际上,越是绞尽脑汁想出应对未来每种可能功能的设计,就越会付出更多时间,同时,当功能需求真的出现时,你依然需要做大量的修改。最好的方式是,学习怎样通过简单、增量的设计来持续地应对变化。
阻碍重构的因素
- 测试不足:使用 TDD 的话,构建进系统的每一小块逻辑都有对应的快速测试。这些测试可以给予你充足的信心来构建更好的代码。相反,如果你只有少量的快速单元测试,随之而来的是更低的测试覆盖率,重构的热情和重构的能力会大大缩减。
- 生存周期长的分支::如果曾经合并过包含大量改动的其他分支中的代码,那么你应该知道大量的重构会使合并代码异常困难。在一个分支上工作的开发者或许会被要求最小化改动范围。这样做可能会使代码合并更简单些,但也会使代码库从此深受煎熬。
- 与实现相关的测试:在测试驱动开发中,类的行为是通过其公有接口来展现的。按定义来说,重构是在不改变其外在公有行为的前提下改变设计的。如果测试知道内部私有实现细节的话,那么在这些私有细节改变时,测试就有失败的可能。大量的模拟或协作者存根会向测试暴露一些本该私有的细节。使用得当的话,使用测试替身不会导致问题。但如果随意使用,你可能会在重构时发现许多测试失败。这也是很多开发者不愿意重构的好借口。
- 大量的技术债务:大量晦涩难懂的代码足以让许多开发者放弃重构。“我该从哪下手呢?”越是任由代码退化,越是难以对它做出改变。
- 缺乏知识:任何你不知道的事情都能够且终将绊倒你。
- 着迷于提前的性能优化:本书中所提倡的许多观点都是基于小的类和函数,它们会带来创建额外对象、调用额外函数的开销。确保先创建一个整洁、可维护的设计。得到一个适合设计的性能数据来判断它是否有性能缺陷。仅对必须优化的代码进行优化。大多数优化会增加理解和维护代码的难度。
- 绩效考核:HAHAHA。
- 一味的追求速度::“发布吧!不要花时间重构了!”你可以责怪工程经理不理解保持系统设计整洁的重要性吗?当然,可能看上去你的进度在一段时间内是快了些,但任由系统质量变差终会将你推入深渊。
总结
在使用 TDD 时需要考虑三条规则:
- 确保代码具备很强的可读性和表达力。
- 在和第一条规则不冲突的情况下,消除所有的重复。
- 不要向系统引入不必要的复杂性。避免猜测行的结构关系和不能增加系统表达力的抽象。
重复代码或许使维护代码库最大的开销,因此时刻谨记将增量重构作为 TDD 环节的一部分,可以避免系统级的退化,文中以设计一个股票交易系统为例。
- 不仅仅是生产代码,测试代码中避免重复同样重要。
- 获取在代码层面没有明显重复,但是在逻辑上是有多余的点,例如例子中判断是否持仓为空与查询股票数量的关系,通过去除 m_isEmpty 变量,让 IsEmpty() 查询股票的数量,我们可以消除这一概念重复。
- 算法的重复(解决同一问题的不同方法或问题的不同部分)会随系统增长演变为重大问题。通常来说,随着对一个实现的改动未能编写进其他实现,重复代码会演化为不经意的变体。
- 抽离出公用的代码,避免重复。
- 提取短小的方法有许多好处,职责单一,功能逻辑清晰,表达力强,同时也是避免重复的强力工具,只有将这些子功能提取出才有复用的可能。
何时探讨设计:
- 预先设计是一个很好的起始路线图。围绕此设计的讨论有助于发现软件中必须要做的东西,以及最初该怎样设计软件。但是,打造系统所需要的大量细节会发生变化。
- TDD 允许你基于当前的业务需求,保持一个可能的最简设计。如果一直保持设计尽量简洁,那么就可以最大可能地引入新的、从未被考虑过的功能。相反,如果任由系统退化(并有大量的重复代码和晦涩难懂的代码),未来有任何新的需求时,你将痛苦万分。
简单设计原则与经典设计原则的冲突:
- 访问性:如何抉择应不应该为测试方便,放松对于成员私有化的标准。
- 及时性:老派的设计希望你能尝试获得尽可能完美的设计。在简单设计中,更应该考虑如何通过简单、增量的设计应对持续的变化。