学习一门新的语言意味着要采用新的惯用法。这章将介绍或者可能重新强调一些惯用法。你会发现这些惯用法在你要在代码中实现设计模式时候是非常有用的。
在这里总结的许多编程惯用法都是很值得做为单独一个章节的,甚至一本书的。你应该把这章做为PHP模式设计使用惯用法的相关介绍,而且查看一些列出的参考书来进行更深入的学习。
测试你的代码
可能没有什么代码惯用法比测试代码更加重要了。好的测试可以提高开发速度。
可能一开始,这句格言会和你的直觉相矛盾。你可能会断言,测试是自由的障碍物。事实上恰恰相反,如果你十分完整的运行那些测试来检查你的软件的公共接口,你就可能在不改变(或者更加糟糕,破坏)原来的应用软件的前提下改变自己系统内在的执行。测试并检验你的公共接口的精确性和正确性,并且让自己随意改变一些代码的内在工作来确保你的软件是正确而且没有bug(错误)。
在讨论更多关于测试的好处之前,先让我们看一个示例。这本书里面所有的测试实例都使用了PHP测试框架——SimpleTest 。这个测试框架可以在 http://simpletest.org 获取到。
考虑下面的代码
上面的代码首先定义了一个常量——TAX_RATE,和一个计算销售税的函数。接着,代码包含了使用SimpleTest框架的必备组件:单体测试本身和一个用来显示测试结果的“reporter”模块。
类TestingTestCase继承于SimpleTest框架的UnitTestCase类。通过扩展UnitTestCase,类TestingTestCase里面所有使用Test开头的方法都将被认为是测试实例——创造条件来调试你的代码并断言结果。
TestingTestCase定义了一个测试,TestSalesTax(),它包含了一个断言函数AssertEqual()。如果它的前两个输入参数是相等的,它将返回true,否则返回false。(如果你想显示assertEqual()失败的信息,你可以传入三个参数就像这样$this->assertEqual(7,calculate_sales_tax(100), “The sales tax calculation failed”))。
代码的最后两行创建了这个测试实例的实体并且使用一个HtmlReporter运行了它。你可以访问这个web页面来运行这个简单的测试。
运行这个测试将显示测试名称,失败断言的详细情况和一个总结条。(绿色的意味着成功(所有的断言都通过了),而红色的暗示着失败(至少有一个断言没有通过))
(assertion(断言)在软件开发中是一种常用的调试方式,很多开发语言中都支持这种机制。在实现中,assertion就是在程序中的一条语句,它对一个boolean表达式进行检查,一个正确程序必须保证这个boolean表达式的值为true;如果该值为false,说明程序已经处于不正确的状态下,系统将给出警告或退出。一般来说,assertion用于保证程序最基本、关键的正确性。assertion检查通常在开发和测试时开启。为了提高性能,在软件发布后,assertion检查通常是关闭的。)
注:(assertion(断言)在软件开发中是一种常用的调试方式,很多开发语言中都支持这种机制。在实现中,assertion就是在程序中的一条语句,它对一个boolean表达式进行检查,一个正确程序必须保证这个boolean表达式的值为true;如果该值为false,说明程序已经处于不正确的状态下,系统将给出警告或退出。一般来说,assertion用于保证程序最基本、关键的正确性。assertion检查通常在开发和测试时开启。为了提高性能,在软件发布后,assertion检查通常是关闭的。)
上面的代码有一个(有意的)错误,所以运行是不能通过了,显示结果如下:
Calculate_sales_tax()这么一个简单的才一行的函数哪里出错了呢?你可能已经注意到这个函数没有返回结果。下面是正确的函数:
function calculate_sales_tax($amount) {
return round($amount * TAX_RATE,2);
}
修改后运行,测试通过。
但是一个简单的测试并不能保证代码是稳定的。比如,你把calculate_sales_tax()改成 function calculate_sales_tax($amount) { return 7; },代码也会通过测试,但只有当1美元等价于100的时候才是正确的。你可以自己增加一些额外的测试方法来测试其他的静态值。
function TestSomeMoreSalesTax() {
$this->assertEqual(3.5, calculate_sales_tax(50));
}
或者改变函数TestSalesTax()来验证第二个(和第三个,等等)值,如下所示
function TestSalesTax() {
$this->assertEqual(7, calculate_sales_tax(100));
$this->assertEqual(3.5, calculate_sales_tax(50));
}
到目前为止还有一种更好的方法,就是新增加一个测试:选择随即值来测试你的代码。具体如下:
function TestRandomValuesSalesTax() {
$amount = rand(500,1000);
$this->assertTrue(defined(‘TAX_RATE’));
$tax = round($amount*TAX_RATE*100)/100;
$this->assertEqual($tax, calculate_sales_tax($amount));
}
TestRandomValuesSalesTax()引入了方法assertTrue(),如果传入的第一个变量等于于布尔真则assertTrue()通过。(和方法assertEqual()一样,方法assertTrue()在接受一个可选择性的、额外的后将返回一个失败的信息)。所以TestRandomValuesSalesTax()首先认为常量TAX_RATE已经定义了,然后使用这个常量来计算随机选择的的数量的税收。
但是TestRandomValuesSalesTax()也存在一个问题:它很大程度的依赖于方法calculate_sales_tax()。测试是应该和特殊的实现细节无关的。一个更好的测试应该只建立一个合理的分界线。接下来的这个测试假定销售税永远不会超过20%。
function TestRandomValuesSalesTax() {
$amount = rand(500,1000);
$this->assertTrue(calculate_sales_tax($amount)<$amount*0.20);
}
确保你的代码正常工作是测试的首要的目的,但是在测试你的代码时候,你应该认识到除此之外还有一些额外的,相对次要的目的:
-
测试让你书写容易测试的代码。这使得代码松散耦合,复杂设计,而且具有很好的模块性。
-
测试能让你清晰的了解运行代码的期望结果,让你从一开始就注重于模块的设计和分析。通过测试,也会让你考虑所有可能的输入和相应的输出结果。
-
测试能很快速的了解编码的目的。换句话说,测试事例扮演着“实例”和“文档”的功能,准确的展示着如何构建一个类,方法等。在这本书中,我有时候通过一个测试事例来演示代码的期望功能。通过读取一个测试方法的声明,你可以清楚的了解代码是如何运行的。一个测试实例定义在代码在明确惯用法下的运行情况。
最后,如果你的测试集——测试实例的集合——是非常彻底的,而且当所有的测试都通过的时候,你可以说你的代码是完备的。有趣的是,这个观点也恰好是Test Driven Development(测试驱动开发)的特征之一。
Test Driven Development(TDD)也被认为是Test First Coding(编码前测试)。Test First Coding是一种把测试更提前一步的方法:在你写任何代码之前先写好测试。你可以从http://xprogramming.com/xpmag/testFirstGuidelines.htm下载到一份很好的,简洁的关于TDD的摘要文章,同时下载到一本很好的关于策略的入门书——Kent Beck著作的《Test Driven Development:By Example》(这本书的例子都是用JAVA开发的,但其中代码的可读性是很好的,而且对主题的介绍和说明都做的很好的)。
注:敏捷开发(Agile Development)
最近,单体测试——特别是测绘驱动开发——已经和敏捷开发方法学紧密的联系起来了,比如说极限编程(XP)。极限编程的焦点关注于快速的反复的发步功能性的代码给客户,并把变化的客户需求做为开发过程中的必备部分。下面是一些关于学习敏捷编程的在线资源:
函数性测试
这本书里面的大部分测试例子都是用来测试面对对象的代码,但是所有形式的编程都可以从中得到收获的。单体测试框架,比如说PHPUnits和SimpleTest,也都能很容易的用来测试功能函数的。例如上面的SimpleTest例子,它就是用来测试calculate_sales_tax()函数的。世界各地的程序员们:把单体测试用例放到你的函数库里面吧!
我希望经过上面的讨论后,你也会被带动起来——“测试引导”(Test Infected)!(这个术语,原创于Erich Gamma,详细情况请见文章http://junit.sourceforge.net/doc/testinfected/testing.htm),就象Gamma所写的那样,刚开始你可能会感到测试是很繁琐的,但是当你为你的程序搭建好一个广阔的测试集后,你将你的代码更加自信!
重构
即使最有思想性且最熟练的程序员也不能预见一个软件项目中的任何细微之处。问题总是出乎意外的出现,需求也可能在变化,结果是代码被优化,共享然后代替。
重构是一个惯用的方法:检查你所有的代码,找出其中能统一化和简单化的共同或者类似之处,使得你的代码更加容易维护和扩展。重构也包括探索一个设计模式是否能够应用到这个具体的问题上——这也能使解决方案简单化。
重构,简单点说是重命名一个属性或者方法,复杂点说是压缩一个已有的类。改变你的代码使得它符合一个或者更多的设计模式是另外一种重构——读完这本书后,你可能会去实现的。
没有什么能比例子来更好的解释重构了!
让我们考虑两个简单的类:CartLine和Cart。CartLine记录了购物车里面每个项目的单件价格和数量。比如CartLine可能记录着“四见红色的polo衬衣,每件19.99$”。Cart 是一个容器,用来装载一个或者更多的CartLine对象并执行一些相关的计算工作,比如购物车里面的所有商品的总花费。
下面是CartLine和Cart的简单实现:
// PHP5
class CartLine {
public $price = 0;
public $qty = 0;
}
class Cart {
protected $lines = array();
public function addLine($line) {
$this->lines[] = $line;
}
public function calcTotal() {
$total = 0;
// add totals for each line
foreach($this->lines as $line) {
$total += $line->price * $line->qty;
}
// add sales tax
$total *= 1.07;
return $total;
}
}
重构的第一步必须有足够的测试来覆盖你所有的代码。这样才能保证你修改的代码不能产生和你原来代码不同的结果。顺便提一下,除非你改变了需求(你代码期望的结果)或者在测试实例中发现了错误,你的测试代码是是不能改变的。
下面是一个测试CartLine和Cart的例子,它在重构的过程中是不会改变的。
function TestCart() {
$line1 = new CartLine;
$line1->price = 12; $line1->qty = 2;
$line2 = new CartLine;
$line2->price = 7.5; $line2->qty = 3;
$line3 = new CartLine;
$line3->price = 8.25; $line3->qty = 1;
$cart = new Cart;
$cart->addLine($line1);
$cart->addLine($line2);
$cart->addLine($line3);
$this->assertEqual(
(12*2 + 7.5*3 + 8.25) * 1.07,
$cart->calcTotal());
}
看着上面的代码,你可能会发现它们有一些“code smells”(代码臭味)——有着古怪的样子而且看起来好像是有问题的代码——它们就像重构的候选项。(更多关于code smells的资料请看http://c2.com/cgi/wiki?codesmell)。两个最直接的重构候选者是注释和计算(与销售税等相关的计算)。重构的一种形式:析取函数(Extract Method)将把这些难看的代码从cart::calcTotal()中提取出来,然后用一个合适的方法来替代它,从而使得代码更加简洁。
比如,你可以增加两个计算方法:lineTotal()和calcSalesTax():
protected function lineTotal($line) {
return $line->price * $line->qty;
}
protected function calcSalesTax($amount) {
return $amount * 0.07;
}
现在你可以重写calcTotal()函数:
public function calcTotal() {
$total = 0;
foreach($this->lines as $line) {
$total += $this->lineTotal($line);
}
$total += $this->calcSalesTax($total);
return $total;
}
到目前为止的改动都是有意义的(至少在这个例子的上下文中),它对于再次暂停和运行这些代码来验证结果依然正确是很有帮助的。记得,一个绿色的成功条的显示出来了!(译者注:本章开始时,作者提及到:绿色的条意味着测试都通过了。)
然而,目前的代码依然有一些可以挑剔的地方。其中一个就是在新方法lineTotal()中存取公共属性。很明显计算每行的之和的责任不应该属于Cart类,而应该在类CartLine里面实现。
再次重构,在CartLine中增加一个新的方法total()用来计算订单里面的每个项目的长期价钱。
public function total() {
return $this->price * $this->qty;
}
然后从类Cart中移除方法lineTotal(),并改变calcTotal()方法来使用新的cartLine::Total()方法。重新运行这个测试,你依然会发现结果是绿色条。
全新重构后的代码就是这样:
class CartLine {
public $price = 0;
public $qty = 0;
public function total() {
return $this->price * $this->qty;
}
}
class Cart {
protected $lines = array();
public function addLine($line) {
$this->lines[] = $line;
}
public function calcTotal() {
$total = 0;
foreach($this->lines as $line) {
$total += $line->total();
}
$total += $this->calcSalesTax($total);
return $total;
}
protected function calcSalesTax($amount) {
return $amount * 0.07;
}
}
现在这代码不再需要每行注释了,因为代码本身更好的说明了每行的功能。这些新的方法,更好的封装了计算这个功能,也更加容易适应将来的变化。(比如说,考虑不同大的销售税率)。另外,这些类也更加平衡,更容易维护。
这个例子显然是微不足道的,但是希望你能从中推断并预想出如何重构你自己的代码。
在编码的时候,你应该有出于两种模式中的一种:增加新的特征或者重构代码。当在增加特征的时候,你要写测试和增加代码。在重构的时候,你要改变你原有的代码,并确保所有相关的测试依然能正确运行。
关于重构的主要参考资料有Martin Fowler著作的《重构:改进原有代码的设计》(Refactoring:Improving the Design of Existing Code)。用一些精简点来总结Fowler的书,重构的步骤如下所示:
定义需要重构的代码
有覆盖所有代码的测试
小步骤的工作
每步之后都运行你的测试。编码和测试都是相当重复的——和编译型语言相比,解释型语言,比如PHP是容易很多的。
使用重构来使你的代码有更好的可读性和可修改性。