65.9K
CodeProject 正在变化。 阅读更多。
Home

使用值对象对属性进行建模

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.75/5 (17投票s)

2010年5月17日

CPOL

15分钟阅读

viewsIcon

31813

downloadIcon

150

在对对象属性进行建模时,要超越基本数据类型

引言

在对对象进行建模时,简单的属性常常得不到应有的关注。最省事的做法是使用基本数据类型(如LongString)来建模属性。这样做就错失了创建更有意义、更健壮代码的机会。本文以Temperature属性为例,从一个简单的实现出发,逐步转向更面向对象的实现。在此过程中,我们将了解值对象的优势及其实现方法。

背景

我们学到的第一个面向对象技术就是“找出名词”。像CustomerInvoiceProduct这样的实体,它们会从需求规格书中跃然纸上,然后我们就可以着手处理了。

这种方法的弊端在于,它会让我们专注于一类对象,而忽略其他非常有用的对象类型。属性就是很好的例子。属性是另一个对象的属性或特征。它们与实体不同,因为它们没有自己的身份。所以,虽然客户是一个实体并且有身份,但客户的信用评分是一个属性。它只是一个数字,它本身没有身份。

属性也可能像名词一样从需求中跳出来,但因为它们“仅仅是属性”,所以可以被忽略。在匆忙识别实体并将其实现为对象时,我们通常会使用脑海中最先想到的数据类型来表示属性。信用评分?整数应该可以。地址?一对string就能搞定。账户余额?如果需要分,就用Double,如果不需要,就用Long

超越基本数据类型

基本数据类型也存在一个问题,就是它们只能存储一个值。在实际的解决方案中,属性通常是多个值的组合。温度包含一个值和一个单位(100华氏度)。地址可能包含多行文本、邮政编码、州等,所以即使是这些非常简单的例子,也无法用单个基本数据类型解决。

在本文的剩余部分,我将实现一个同时具有Value(值)和Scale(单位)的Temperature属性。我们将从一个简单的实现开始,使用基本数据类型,最终实现一个功能齐全的对象,它不仅能表示温度,还能开启无限的可能性。

让我们从简单的实现开始。我们可以使用两种不同的数据类型:一个double用于value,一个string用于scale。这样做是可行的,但意味着我们必须时刻记住将这两个值放在一起。例如,如果我们需要将温度传递给一个函数,就需要两个参数。如果一个类有一个温度属性,就需要将其实现为两个属性。

Public Class WeatherReading
    Public TemperatureValue As Double
    Public TemperatureScale As String
End Class

为温度属性赋值需要分两步进行。

Dim reading As New WeatherReading
reading.TemperatureValue = 100
reading.TemperatureScale = "Fahrenheit"

关于这些属性,唯一能说明它们相关联的就是在名称中使用“Temperature”这个词。我们可以做得更好。如果我们使用一个结构体,就可以将值和单位显式地组合成一个单一的Temperature数据类型。

Public Structure Temperature
    Dim Value As Double
    Dim Scale As String
End Structure

Public Class WeatherReading
    Public Temperature As Temperature
End Class

因此,我们已经从基本数据类型分离出来,创建了自己的类型,已经看到了好处。温度值和温度单位之间的关系现在是明确的。我们可以将温度数据作为一个变量来处理。

Public Sub foo(ByVal temp as Temperature)
End Sub

现在,为类的Temperature属性赋值是一个一步完成的过程。

Dim reading As New WeatherReading
reading.Temperature = temp

temp变量最初是如何被赋值valuescale的,这是一个独立的问题,我们稍后会讨论。现在,我们很高兴温度可以作为一个变量来处理。

当然,仍然存在问题。我不喜欢scalestring的事实,它允许使用我们类的程序员输入任何内容。

reading.Temperature.Scale = "Miles"

enum是表示离散值集合的理想方式。在这种情况下,我们的enum为我们需要的各种温度单位中的每一个都提供了一个条目。如果以后我们将系统扩展到处理更多单位,就可以将它们添加到enum中。

Public Enum TemperatureScale
    Celsius = 1
    Fahrenheit = 2
    Kelvin = 3
    Rankine = 4
End Enum

定义了enum后,我们可以修改我们的结构来使用它。

Public Structure Temperature
    Dim Value As Double
    Dim Scale As TemperatureScale
End Structure

完成此操作后,我们只能将enum的成员赋值给scale。

reading.Temperature.Scale = TemperatureScale.Fahrenheit

我们还获得了智能感知的好处。Visual Studio 会在我们需要选择一个时自动列出各种单位。许多这样的小改进累积在一起,使我们的代码更易用、更易读、更易维护。

我们稍微回退一下。我之前提到过,虽然我们可以将温度作为一个变量来处理,但该变量仍然需要同时赋值valuescale。所以,我们仍然面临创建温度变量是一个两步过程的问题:一个赋值给value,另一个赋值给scale

我们当前解决方案中另一个令人烦恼的担忧是,Temperature纯粹是一个数据结构。我们忽略了将一些有用的逻辑与该数据捆绑的可能性。比较两个温度或在不同温度单位之间进行转换的能力将非常有价值。

在面向对象的世界出现之前,我们可能会使用一个结构来表示我们的数据,然后使用单独的函数来操作这些数据。如果我们从对象的角度考虑,就可以将部分行为构建到我们的Temperature对象中。

我最初写这篇文章时,错误地建议当开始向结构中添加逻辑时,应该切换到使用类而不是结构。那是错误的。

结构可以提供类的大部分功能,如构造函数、方法等。结构甚至可以实现接口,但结构不进行实现继承。这并没有人们想象中那么大的损失。继承经常被过度使用和滥用,而且对于适合结构的对象来说,继承通常不是一个好主意。

结构应该在哪里使用以及类是更好的选择,并没有明确的界限。你会发现各种经验法则,但说实话,你不应该过分纠结于此。

我们的Temperature很小,只有两个成员字段,其数据很简单,没有对其他对象的引用,只有一个数值和一个Enum。表面上看,它非常适合作为结构。

就我们目前所做而言,结构和类将起相同的效果。将代码中的“Structure”改为“Class”,即可完成。如果你决定创建和操作几千个温度,你可能会发现结构能提供更好的性能。这主要是由于结构和类实例化方式的差异。

现在,我们坚持使用结构。

属性作为对象

让我们先为我们的结构添加一个构造函数。

Public Structure Temperature
    Public Value As Double
    Public Scale As TemperatureScale
    Public Sub New(ByVal newValue As Double, ByVal newScale As TemperatureScale)
        Value = newValue
        Scale = newScale
    End Sub
End Structure 

构造函数接受valuescale,并使用传递的参数值来设置两个类成员变量。现在我们可以实例化我们的Temperature,并一次性设置ValueScale

Dim temp As New Temperature(96.5, TemperatureScale.Fahrenheit)

ValueScale仍然是类的public成员,这意味着使用我们类的程序员仍然可以直接修改它们。我想阻止他们直接修改valuescale而留下另一个不变。虽然剥夺用户使用我们对象的灵活性可能显得奇怪,但这正是我们应该做的。

灵活性应该是我们刻意在代码中构建的东西。它不应该仅仅因为我们没有认真考虑就自动发生。更多的灵活性意味着更多的使用对象的方式,以及更多的组合对象的方式。所有这些都会导致更多的潜在测试用例和更多的细微bug。最好从暴露尽可能少的功能开始,并根据需要添加更多功能。

如果你深入研究这个概念,你会惊讶于你的类中可以隐藏多少行为,不让外部世界知道。试试看,当你认为你已经隐藏了所有可以隐藏的东西后,再仔细看看。

现在,我们有了一种让我们的结构消费者在实例化Temperature时设置valuescale的方式。我们知道我们希望构建一种机制,用于在不同温度单位之间进行转换。目前我们不需要直接访问valuescale。所以,让我们移除这个选项;如果需要,我们随时可以重新添加它。

Public Structure Temperature
    Private _Value As Double
    Private _Scale As TemperatureScale
    Public ReadOnly Property Value() As Double
        Get
            Return _Value
        End Get
    End Property
    Public ReadOnly Property Scale() As TemperatureScale
        Get
            Return _Scale
        End Get
    End Property
    Public Sub New(ByVal value As Double, ByVal scale As TemperatureScale)
        _Value = value
        _Scale = scale
    End Sub
End Structure 

现在,让我们看看如何添加从一个scale转换为另一个scale的逻辑。对于四种scale中的每一种,还有其他三种我们可能希望转换为的。这意味着有12个转换例程。我们每添加一个新scale,转换例程的数量都会呈指数级增长。

让我们暂时搁置转换例程的具体细节,思考一下程序员如何使用我们完成的类。一种方法可能是这样的:

Dim temp As New Temperature(96.5, TemperatureScale.Fahrenheit)
temp.ConvertTo(TemperatureScale.Celsius)

这很简单。一个函数处理转换。在后台,可能有很多函数用于与每个单位进行相互转换,但使用我们类的用户不必担心这个问题。

请注意,ConvertTo例程会修改temp变量的值和单位。之前,我们将valuescale设为只读,以防止我们的类的消费者修改它们。ConvertTo例程确实会修改它们,但以一种受控的方式。这是暴露足够功能且不多余的一个例子。

值对象

我们目前的状态的结构有效地是一个Value Object。它代表一个value而不是一个有自己身份的entity,并且它添加了一些有用的行为。然而,在现实中,在我们实现一个真正的值对象之前,还需要进一步改进。我们需要让它变得不可变。这意味着一旦我们创建了一个温度,我们就无法以任何方式更改它。

这怎么可能实现呢?我们的“ConvertTo”函数呢?这种改变有什么意义呢?让我们通过看看一个值对象到底是什么,以及一些你可能已经熟悉的不变值对象来开始回答这些问题。

.NET中的基本数据类型都实现为对象。String和整数都有方法。如果你玩弄这些对象和方法,你会注意到一件有趣的事情。你无法调用任何可以改变变量值的方法。让我们看一个经典例子。以下代码看起来似乎会修改str变量(将B替换为D),但它并不会。

Dim str As String = "ABC"
str.Replace("B", "D")

如果我们想修改str变量,将B替换为D,我们需要这样做:

Dim str As String = "ABC"
str=str.Replace("B", "D")

区别很微妙但非常重要。Replace方法不会修改string,而是创建一个全新的string。其他方法如ToLowerToUpper的工作方式完全相同。这是有道理的。如果你改变了一个string,它就不再是同一个string了;根据定义,你正在处理一个全新的string

所以,一个Value Object是在其构造函数中被赋值,并且在此之后不允许再次修改这些值的对象。任何转换对象的函数都应该返回该对象的一个全新实例。

在我们的Temperature示例中,我们仍然可以使用我们的ConvertTo函数,但它不应该修改温度的valuescale,而是应该返回一个具有适当valuescale的全新的Temperature对象。

Dim temp As New Temperature(96.5, TemperatureScale.Fahrenheit)
temp = temp.ConvertTo(TemperatureScale.Fahrenheit)

这是“怎么做”;现在,让我们看看“为什么”。我们需要承认的第一件事是,Value Object捕捉了现实世界的一个方面。像温度这样的值没有身份。如果一个温度从20度升高到30度,它就不再是同一个温度了。我们不认为数字6是数字2的修改版本。它们是不同的数字。

CustomerEmployee这样的实体可以以各种方式改变,但仍然是同一个CustomerEmployee。当我们使用Value Object时,我们就向阅读我们代码的任何人发送了一条消息。我们强调了值和实体之间的区别。

Value对象也是通过减少选项来简化事情的另一个例子。如果我们确定一个对象不会改变,这会给我们信心,缩小可能出错的范围,并使调试更容易。

要看到这一点,请考虑当我们像参数一样将一个对象传递给一个例程时会发生什么。如果我们传递一个参数 ByRef,我们就接受它可以被改变;而传递 ByVal 的参数则不能被改变。当我们传递对象时,这些规则实际上并不适用。

当我们传递一个对象时,我们实际上传递的是该对象的地址。这意味着 ByRefByVal 的工作方式可能不像我们期望的那样。传递 ByRef 的对象地址可以被改变,这意味着当例程结束时,作为参数传递的变量可能指向完全不同的对象实例。

传递 ByVal 的对象地址不能被改变,这意味着当例程结束时,参数仍然指向同一个对象实例。例程仍然可以做一些改变对象状态的事情,所以传递 ByVal 并不能保证我们的对象保持不变。

如果我们实现一个对象为Value Object,那么我们就知道它不会被修改。ByValByRef的行为与预期一致。一个传递 ByValValue Object无论例程做什么都不会改变。一个传递 ByRefValue Object可以被改写成指向一个新的object实例。

实现一个Value Object再简单不过了。我们提供一个构造函数来设置类的成员变量,并且不提供其他修改它们的方法。我们的转换函数应该返回一个全新的Temperature对象。仅此而已。

Public Sub New(ByVal value As Double, ByVal scale As TemperatureScale)
    _value = value
    _scale = scale
End Sub

Public Function ConvertTo(ByVal scale As TemperatureScale) As Temperature
    Dim newValue As Double
    ' Calculate New Value Here
    Return New Temperature(newTemp, scale)
End Function

完成此操作后,我们与温度对象交互的方式变得稍微有限,但我们可以用它实现的事情却一点也不受限制。我们仍然可以创建一个温度,并将其转换为任何单位。

Dim a As New Temperature(25, TemperatureScale.Celsius)
Dim b As New Temperature = a.ConvertTo(TemperatureScale.Fahrenheit)

在上面的代码运行后,变量a仍然具有相同的单位和值(25摄氏度)。你对a所做的任何事情都不会改变它的value。你可以做的是让a指向一个全新的Temperature对象,而该对象可以是a本身的产物。

Dim a As New Temperature(25, TemperatureScale.Celsius)
a = a.ConvertTo(TemperatureScale.Fahrenheit)

这个例子和上面的例子唯一的区别是,我们没有使用一个单独的变量来保存转换结果,而是将结果存回了同一个变量。这不应该是一个太大的思想上的跳跃,我们总是以对象值的方式来处理它。考虑以下两行代码。

b = a + 1
a = a + 1

运算符重载

这最后一段代码和之前的代码之间有一个有趣的区别,那就是使用了“+”运算符。前面的例子使用了“ConvertTo”函数。实际上,这两种做事方式基本相同。“+”运算符实际上是一个函数,它接受两个参数,将它们相加,并将结果作为一个新值返回。

a+b   : add(a,b)
a-b   : subtract(a,b)

整数、双精度浮点数,甚至string这样的基本数据类型都有为它们定义的运算符。并非所有运算符都适用于所有数据类型。你可以使用+&来连接两个string,但为string定义乘法(*)或除法(/)运算符是没有意义的。

这种运算符表示法看起来可能对我们的Temperature对象很有用。能够将两个温度相加而不关心它们是否是相同的单位,难道不是很棒吗?如何检查一个温度是否大于另一个温度?

在.NET提供的各种运算符中,以下运算符似乎对我们的Temperature很有用。

算术运算符用于执行涉及数值计算的算术运算。

+   : Addition
-   : Subtraction

可以进行乘法和除法,但我想不出任何理由要将一个温度乘以或除以另一个温度。比较运算符用于比较操作数,并根据比较是否为真返回逻辑值。

=   : Equality
<>  : Inequality
<   : Less than
>   : Greater than
>=  : Greater than or equal to
<=  : Less than or equal to

实现数学运算符涉及在我们的value对象上创建一个public函数,该函数接受两个与value对象本身相同类型的参数。通俗地说,我们需要在我们的Temperature结构上有一个函数,它接受两个Temperature。该函数还应该返回一个temperature(即相加或相减提供的两个temperature的结果)。

Public Overloads Shared Operator +(ByVal a As Temperature, 
   ByVal b As Temperature) As Temperature
    Dim interimB As Temperature = b.ConvertTo(a.Scale)
    Return New Temperature(a.Value + interimB.Value, a.Scale)
End Operator

我们需要对如何实现数学运算符做出一些决定。这并不像你想象的那么简单。如果我们相加一个Celsius温度和一个Fahrenheit温度,我们结果的单位应该是多少?

按照惯例,我们将假定结果的单位与最左边的操作数相同。所以,如果“a”是摄氏度而“b”是华氏度,那么a + b将产生一个摄氏度值。有了这个约定,我们的加法方法就很简单了。我们将b转换为与a相同的单位,然后将它们的值相加。

Dim interimB As Temperature = b.ConvertTo(a.Scale)
Return New Temperature(a.Value + interimB.Value, a.Scale)

减法的工作方式也类似。同样,结果将采用与最左边值相同的单位。

比较的实现非常相似。我们编写一个函数,它接受两个Temperature参数。唯一的区别是,比较运算符的结果是Boolean而不是另一个Temperature。为了比较两个Temperature,我们将它们都转换为相同的单位,然后比较值。

Public Overloads Shared Operator =(ByVal a As Temperature, _
                 ByVal b As Temperature) As Boolean
    Dim interimB As Temperature = b.ConvertTo(a.Scale)
    Return a.Value = interimB.Value
End Operator

一旦我们编写了一个运算符,我们就可以用它来实现它的反运算符。不等于(<>)是等于(=)的反运算符。

Public Overloads Shared Operator <>(ByVal a As Temperature, _
                 ByVal b As Temperature) As Boolean
    Return Not a = b
End Operator

大于或等于(>=)是小于(<)的反运算符。

Return Not a < b

运算符重载是另一个对于结构和类都以相同方式工作的特性。

这就是Value对象的内容。

© . All rights reserved.