当前位置:首页 > php设计模式介绍之值对象模式

php设计模式介绍之值对象模式

点击次数:1636  更新日期:2011-01-04
\n

上文:《PHP设计模式介绍》第一章 编程惯用法


\n

《PHP设计模式介绍》第二章 值对象模式


\n

在所有的最简单的程序中,大多数对象都有一个标识,一个重要的商业应用对象,例如一个Customer或者一个SKU,有一个或者更多的属性—id,name,email地址,这样可以把它从同一个类的其他实例区分开来。此外,对象有一个恒定的标识:它是贯穿于整个应用程序的一个唯一的标识,对于程序员来说,”customer A”在任何地方就是”customer A”,并且只要你的程序在持续运行时”customer A”仍然是”customer A”。 但是一个对象不需要有一个标识。有些对象仅仅是为了描述其他对象的属性。


\n

例如:通常用一个对象描述一个日期、一个数字或者货币。日期、整数或美元的类定义是都是便于使用的、快捷、便于封装的,并且方便进行拷贝,相互比较,甚至是创建。


\n

从表面上看,这些描述简单的对象很容易被执行:它们的语句非常少,在构造类时无论是应用于Customer还是SKU都没有什么不同。这个想法似乎是正确的,但是所谓的”似乎正确”很容易产生一些bug。


\n

请看下面的代码,这是一个关于以美元给员工发放工资的对象的定义和执行操作。多数情况下,它的运行是没有问题的。(这个类被命名为BadDollar,因为它还存在着bug)。考虑一下,看你是否能发现它的bug。


\n

// PHP5
class BadDollar {
protected amount;
public function __construct(amount=0) {
this->amount = (float)amount;
}
public function getAmount() {
return this->amount;
}
public function add(dollar) {
this->amount += dollar->getAmount();
}
}


\n

class Work {
protected salary;public function __construct() {
this->salary = new BadDollar(200);}
public function payDay() {
return this->salary;
}
}
class Person {
public wallet;
}


\n

function testBadDollarWorking() {
job = new Work;
p1 = new Person;
p2 = new Person;
p1->wallet = job->payDay();
this->assertEqual(200, p1->wallet->getAmount());
p2->wallet = job->payDay();
this->assertEqual(200, p2->wallet->getAmount());
p1->wallet->add(job->payDay());
this->assertEqual(400, p1->wallet->getAmount());
//this is bad — actually 400
this->assertEqual(200, p2->wallet->getAmount());
//this is really bad — actually 400
this->assertEqual(200, job->payDay()->getAmount());
}


\n

那么, bug是什么呢?如果不能上面的代码例子中直观地发现问题,这里有个提示:雇员对象p1和对象p2使用着同一个BadDollar对象实例。


\n

首先,类Work和类Person的实例已经创建。那么,假设每一个雇员最初有一个空的电子钱包,雇员的电子钱包Person:wallet是通过Work::payDay()函数返回的对象资源变量赋值的,所以被设定为一个BadDollar类的对象实例。


\n

还记得PHP5的对象赋值处理方式吗?因为PHP5的对象赋值的处理方式,所以job::salary,、p1::wallet和p2::wallet这三个看上去不同的对象实例虽然使用着不同的“标识符”,但是事实上,它们全部都指定到同一个对象实例。


\n

因此,接下来的发放工资的操作(PayDay表示发放工资的日子,这里表示发放工资的动作),使用job->payDay()本来仅仅是想增加P1的工资,却出乎意料地次给P2也发放了。并且,这个动作还改变了工作的基本工资的额度。因此,最后两个值的检测报错。


\n

Value Object PHP5 Unit Test
1) Equal expectation fails because [Integer: 200] differs from [Float: 400] by 200
in testBadDollarWorking
in ValueObjTestCase
2) Equal expectation fails because [Integer: 200] differs from [Float: 400] by 200
in testBadDollarWorking
in ValueObjTestCase
FAILURES!!!


\n

问题


\n

那么,你该如何为Date或Dollar这样一些描述简单的应用定义一个高效的类,并且易于创建呢。


\n

解决方案


\n

高效的对象应该像PHP的整型那样运作:如果你把同一个对象资源赋值给两个不同的变量,然后改变其中的一个变量,另一个变量仍然不受影响。事实上,这就是Value Object模式的目标所在。


\n

执行Value Object时,php4和php5是有区别的。


\n

正如以上你所看到的,PHP5通过new进行对象资源的赋值传递的是对象资源的指针就像我们在PHP4中通过指针传递一样。很明显,这是一个问题。为了解决那个问题并实现一个专有对象Dollar的值,我们必须使属性amount的对象的所有属性的一个值在一般情况下不可变或不能改变。但是在PHP语言的没有提供参数不可改变的功能的情况下,你完全可以结合属性的可见性与获得和设置方法来实现。


\n

相反地,PHP4操作所有的对象都是遵循Value Objects对象规律的,因为PHP4的赋值操作相当于对对象做了一个拷贝。所以为了在PHP4中实现Value Objects设计模式你需要打破你细心地培养的通过指针赋值来创建、传递、提取对象的习惯。


\n

注:术语 不可变的(Immutable):


\n

在词典中Immutable的定义是不允许或不易受影响。在编程中,这个术语表示一个一旦被设置就不能改变的值。


\n

PHP5 样本代码:


\n

既然我们开始用PHP5编写代码,让我们优化一个PHP5的Value Object的实例并创建一个较好的Dollar类定义。命名在面向对象编程中非常重要,选择一个唯一的货币类型作为这个类的名字,说明它不被定义为可以处理多种货币类型的类。


\n

class Dollar {
protected amount;
public function __construct(amount=0) {
this->amount = (float)amount;
}
public function getAmount() {
return this->amount;
}
public function add(dollar) {
return new Dollar(this->amount + dollar->getAmount());
}
}


\n

类里面的属性如果加上protected前缀,别的类是访问不了的。protected(和private)拒绝通过属性直接被访问。


\n

通常,当你使用面向对象进行编程的时候,你经常需要创建了一个“setter”函数,就类似于:


\n

public setAmount(amount)
{
this->amount=amount;
}


\n

一样,在这种情况下,虽然没有设定函数Dollar::amount(),但在对象的实例化期时,参数Dollar::amount就已经被赋值了。而函数Dollar::getAmount()只是提供一个访问Dollar属性的功能,在这里访问的数据类型为浮点型。


\n

最有趣的变化是在Dollar::add()方法函数中。并不是直接改变this->amount变量的值从而会改变已存在的Dollar对象实例,而是创建并返回一个新的Dollar实例。现在,尽管你指定当前对象给多个变量,但是每一个变量的变化都不会影响其它的变量实例。


\n

对于价值设计模式不变性是关键,任何对于一个Value Object的变量amount的改变,是通过创建一个新的带有不同预期值的类的实例来完成的。上文中提高的最初那个this->amount变量的值从未改变。


\n

简单来说,在PHP5里面使用价值设计模式时,需要注意以下几个方面:


\n

    \n
  1. 保护值对象的属性,禁止被直接访问。
    \n
  2. 在构造函数中就对属性进行赋值。
    \n
  3. 去掉任何一个会改变属性值的方式函数(setter),否则属性值很容易被改变。

\n

以上三步创建了一个不变的值,这个值一旦被初始化设置之后就不能被改变。当然,你也应该提供一个查看函数或者是访问Value Object的属性的方法,并且可以添加一些与这个类相关的函数。值对象并不是只能用在一个简单的架构上,它也可以实现重要的商务逻辑应用。让我们看看下一个例子:


\n

详细例子


\n

让我们在一下更加复杂的例子中查看值对象模式的功能。


\n

让我们开始实现一个的基于PHP5中Dollar类中的一个Monopoly游戏。


\n

第一个类Monopoly的框架如下:


\n

class Monopoly {
protected go_amount;
/**
* game constructor
* @return void
*/
public function __construct() {
this->go_amount = new Dollar(200);
}
/**
* pay a player for passing 揋o?/span>
* @param Player player the player to pay
* @return void
*/
public function passGo(player) {
player->collect(this->go_amount);
}
}


\n

目前,Monopoly的功能比较简单。构造器创建一个Dollar类的实例go_amount,设定为200,实例go_amount常常被passtGo()函数调用,它带着一个player参数,并让对象player的函数collect为player机上200美元.


\n

Player类的声明请看下面代码,Monoplay类调用带一个Dollar参数的Player::collect()方法。然后把Dollar的数值加到Player的现金余额上。另外,通过判断Player::getBalance()方法函数返回来的余额,我们可以知道使访问当前Player和Monopoly对象实例是否在工作中。


\n

class Player {
protected name;
protected savings;
/**
* constructor
* set name and initial balance
* @param string name the players name
* @return void
*/
public function __construct(name) {
this->name = name;
this->savings = new Dollar(1500);
}
/**
* receive a payment
* @param Dollar amount the amount received
* @return void
*/
public function collect(amount) {
this->savings = this->savings->add(amount);
}
* return player balance
* @return float
*/
public function getBalance() {
return this->savings->getAmount();
}
}


\n

上边已经给出了一个Monopoly和Player类,你现在可以根据目前声明的几个类定义进行一些测试了。


\n

MonopolyTestCase的一个测试实例可以像下面这样写:


\n

class MonopolyTestCase extends UnitTestCase {
function TestGame() {
game = new Monopoly;
player1 = new Player(‘Jason’);
this->assertEqual(1500, player1->getBalance());
game->passGo(player1);
this->assertEqual(1700, player1->getBalance());
game->passGo(player1);
this->assertEqual(1900, player1->getBalance());
}
}


\n

如果你运行MonopolyTestCase这个测试代码,代码的运行是没有问题的。现在可以添加一些新的功能。


\n

另一个重要的概念是对象Monopoly中的租金支付。让我们首先写一个测试实例(测试引导开发)。下面的代码希望用来实现既定的目标。


\n

function TestRent() {
game = new Monopoly;
player1 = new Player(‘Madeline’);
player2 = new Player(‘Caleb’);
this->assertEqual(1500, player1->getBalance());
this->assertEqual(1500, player2->getBalance());
game->payRent(player1, player2, new Dollar(26));
this->assertEqual(1474, player1->getBalance());
this->assertEqual(1526, player2->getBalance());
}


\n

根据这个测试代码,我们需要在Monopoly对象中增加payRent()的方法函数来实现一个Player对象去支付租金给另一个Player对象.


\n

Class Monopoly {
// …
/**
* pay rent from one player to another
* @param Player from the player paying rent
* @param Player to the player collecting rent
* @param Dollar rent the amount of the rent
* @return void
*/
public function payRent(from, to, rent) {
to->collect(from->pay(rent));
}
}


\n

payRent()方法函数实现了两个player对象之间(from和to)的租金支付。方法函数Player::collect()已经被定义了,但是Player::pay()必须被添加进去,以便实例from通过pay()方法支付一定的Dollar数额to对象中。首先我们定义Player::pay()为:


\n

class Player {
// …
public function pay(amount) {
this->savings = this->savings->add(-1 * amount);
}
}


\n

但是,我们发现在PHP中你不能用一个数字乘以一个对象(不像其他语言,PHP不允许重载操作符,以便构造函数进行运算)。所以,我们通过添加一个debit()方法函数实现Dollar对象的减的操作。


\n

class Dollar {
protected amount;
public function __construct(amount=0) {
this->amount = (float)amount;
}
public function getAmount() {
return this->amount;
}
public function add(dollar) {
return new Dollar(this->amount + dollar->getAmount());
}
public function debit(dollar) {
return new Dollar(this->amount – dollar->getAmount());
}
}


\n

引入Dollar::debit()后,Player::pay()函数的操作依然是很简单的。


\n

class Player {
// …
/**
* make a payment
* @param Dollar amount the amount to pay
* @return Dollar the amount payed
*/
public function pay(amount) {
this->savings = this->savings->debit(amount);
return amount;
}
}


\n

Player::pay()方法返回支付金额的amount对象,所以Monopoly::payRent()中的语句to->collect(from->pay(rent))的用法是没有问题的。这样做的话,如果将来你添加新的“商业逻辑”用来限制一个player不能支付比他现有的余额还多得金额。(在这种情况下,将返回与player的账户余额相同的数值。同时,也可以调用一个“破产异常处理”来计算不足的金额,并进行相关处理。对象to仍然从对象from中取得from能够给予的金额。)


\n

注:术语——商业逻辑


\n

在一个游戏平台的例子上提及的“商业逻辑”似乎无法理解。这里的商业的意思并不是指正常公司的商业运作,而是指因为特殊应用领域需要的概念。请把它认知为 “一个直接的任务或目标”,而不是“这里面存在的商业操作”。


\n

所以,既然目前我们讨论的是一个Monopoly,那么这里的 “商业逻辑”蕴含的意思就是针对一个游戏的规则而说的。


\n

PHP4样本代码


\n

和PHP5不一样的是,PHP4赋值对象资源的时候是拷贝该对象,这个语法的特点本质上和值对象设计模式要求正好吻合。


\n

然而,PHP4不能控制的属性和方法函数在对象之外的可见性,所以实现一个值对象设计模式相对PHP5也有细微的差别。


\n

假如你回想一下这本书序言中的“对象句柄”部分,它提出了三个 “规则”,当你在PHP4中使用对象去模仿PHP5中的对象句柄时,这三个规则总是适用的:


\n

    \n
  1. 通过指针(obj=&new class;)来创建对象。
    \n
  2. 用指针(function funct(&obj) param{})来传递对象。
    \n
  3. 用指针(function &some_funct() {} returned_obj =& some_funct())来获取一个对象。

\n

然后,值对象设计模式却不能使用上述三个“总是适用”的规则。只有忽视了这些规则,才能总是得到一个PHP4对象的拷贝(这相当于PHP5中的“克隆”操作,描述在http://www.php.net/manual/en/language.oop5.cloning.php)


\n

因为PHP4可以轻松地赋值一个对象—这在PHP语言中是一个固有的行为,所以实现变量的不可更改就需要通过值对象通用协定来实现。在PHP4中,如果要使用值对象,请不要通过指针来创建或获取一个对象,并且给所有需要保护以免外界修改的属性或者方法函数命名时,都在属性和方法函数的名字加上下划线(_)做前缀。按照协定,变量如果具有值对象的属性,应该使用一个下划线来标识它的私有性。


\n

下面是PHP4中的Dollar类:


\n


\n

// PHP4
class Dollar {
var _amount;
function Dollar(amount=0) {
this->_amount = (float)amount;
}
function getAmount() {
return this->_amount;
}
function add(dollar) {
return new Dollar(this->_amount + dollar->getAmount());
}
function debit(dollar) {
return new Dollar(this->_amount – dollar->getAmount());
}
}


\n


\n

下面这个实例可以说明,你不能在PHP4中限制一个属性只能被外部更改:


\n

function TestChangeAmount() {
d = new Dollar(5);
this->assertEqual(5, d->getAmount());
//only possible in php4 by not respecting the _private convention
d->_amount = 10;
this->assertEqual(10, d->getAmount());
}


\n

再重复一次,在所有PHP4对象中,私有变量的前缀使用一个下划线,但是你还是可以从外部来直接访问私有属性和方法函数。


\n

值对象中的商业逻辑


\n

值对象(Value Objects)不仅仅用于最小限度的访问方法这样的简单的数据结构,它同样还可以包括有价值的商业逻辑。考虑以下你如果实现许多人中平均分配金钱。


\n

如果总钱数确实是可以分成整数,你可以生成一组Dollar对象,而且每一个Dollar对象都拥有相同的部分。但是当总数可以整数的美元或者美分的时候,我们该怎么处理呢?


\n

让我们开始用一个简单的代码来测试一下:


\n

// PHP5
function testDollarDivideReturnsArrayOfDivisorSize() {
full_amount = new Dollar(8);
parts = 4;
this->assertIsA(
result = full_amount->divide(parts)
,’array’);
this->assertEqual(parts, count(result));
}


\n

注释 assertIsA:


\n

assertIsA()的作用是让你测试:一个特定的变量是否属于一个实例化的类。当然你也可以用它来验证变量是否属于一些php类型:字符串、数字、数组等。


\n

为了实现上述测试, Dollar::divide()方法函数的编码如下…


\n

public function divide(divisor) {
return array_fill(0,divisor,null);
}


\n

最好加上更多的细节。


\n

function testDollarDrivesEquallyForExactMultiple() {
test_amount = 1.25;
parts = 4;
dollar = new Dollar(test_amount*parts);
foreach(dollar->divide(parts) as part) {
this->assertIsA(part, ‘Dollar’);
this->assertEqual(test_amount, part->getAmount());
}
}


\n

现在,应当返回存有正确数据的Dollar对象,而不是简单的返回数量正确的数组。


\n

实现这个仍然只需要一行语句:


\n


\n

public function divide(divisor) {


\n

return array_fill(0,divisor,new Dollar(this->amount / divisor));


\n


\n

最后一段代码需要解决一个除数不能把Dollar的总数均匀的除开的问题。


\n

这是一个棘手的问题:如果存在不能均匀除开的情况,是第一部分还是最后一部分能得到一个额外的金额(便士)?怎样独立测试这部分的代码?


\n

一个方法是:明确指定代码最后需要实现目标:这个数组的元素数量应该是与除数表示的数量相等的,数组的元素之间的差异不能大于0.01,并且所有部分的总数应该与被除之前的总数的值是相等的。


\n

上面的描述通过正如下面的代码实现:


\n

function testDollarDivideImmuneToRoundingErrors() {
test_amount = 7;
parts = 3;
this->assertNotEqual( round(test_amount/parts,2),
test_amount/parts,
’Make sure we are testing a non-trivial case %s’);
total = new Dollar(test_amount);
last_amount = false;
sum = new Dollar(0);
foreach(total->divide(parts) as part) {
if (last_amount) {
difference = abs(last_amount-part->getAmount());
this->assertTrue(difference <= 0.01);
}
last_amount = part->getAmount();
sum = sum->add(part);
}
this->assertEqual(sum->getAmount(), test_amount);
}


\n

注释 assertNotEqual:


\n

当你要确保两个变量的值是不相同时,你可以用它来进行检验。这里面的值相同是PHP的”==”运算符进行判断的。任何情况下当你需要确保两个变量的值是不相同的时候,你就可以使用它。


\n

现在根据上述代码,如果来构造Dollar::divide()方法函数呢?


\n

class Dollar {
protected amount;
public function __construct(amount=0) {
this->amount = (float)amount;
}
public function getAmount() {
return this->amount;
}
public function add(dollar) {
return new Dollar(this->amount + dollar->getAmount());
}
public function debit(dollar) {
return new Dollar(this->amount – dollar->getAmount());
}
public function divide(divisor) {
ret = array();
alloc = round(this->amount / divisor,2);
cumm_alloc = 0.0;
foreach(range(1,divisor-1) as i) {
ret[] = new Dollar(alloc);
cumm_alloc += alloc;
}
ret[] = new Dollar(round(this->amount – cumm_alloc,2));
return ret;
}
}


\n

这段代码可以正常运行,但是仍然有一些问题,考虑一下如果在testDollarDivide()的开始处改变test_amount 为 0.02; num_parts 为 5;这样的临界条件,或者考虑一下当你的除数不是一个整型数字,你该怎么做?


\n

解决上边这些问题的方法是什么呢?还是使用测试导向的开发循环模式:增加一个需求实例,观察可能的错误,编写代码来生成一个新的实例进行运行,还有问题存在时继续分解。最后重复上述过程。

\n