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






4.75/5 (17投票s)
在对对象属性进行建模时,要超越基本数据类型
引言
在对对象进行建模时,简单的属性常常得不到应有的关注。最省事的做法是使用基本数据类型(如Long
或String
)来建模属性。这样做就错失了创建更有意义、更健壮代码的机会。本文以Temperature
属性为例,从一个简单的实现出发,逐步转向更面向对象的实现。在此过程中,我们将了解值对象的优势及其实现方法。
背景
我们学到的第一个面向对象技术就是“找出名词”。像Customer
、Invoice
和Product
这样的实体,它们会从需求规格书中跃然纸上,然后我们就可以着手处理了。
这种方法的弊端在于,它会让我们专注于一类对象,而忽略其他非常有用的对象类型。属性就是很好的例子。属性是另一个对象的属性或特征。它们与实体不同,因为它们没有自己的身份。所以,虽然客户是一个实体并且有身份,但客户的信用评分是一个属性。它只是一个数字,它本身没有身份。
属性也可能像名词一样从需求中跳出来,但因为它们“仅仅是属性”,所以可以被忽略。在匆忙识别实体并将其实现为对象时,我们通常会使用脑海中最先想到的数据类型来表示属性。信用评分?整数应该可以。地址?一对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
变量最初是如何被赋值value
和scale
的,这是一个独立的问题,我们稍后会讨论。现在,我们很高兴温度可以作为一个变量来处理。
当然,仍然存在问题。我不喜欢scale
是string
的事实,它允许使用我们类的程序员输入任何内容。
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 会在我们需要选择一个时自动列出各种单位。许多这样的小改进累积在一起,使我们的代码更易用、更易读、更易维护。
我们稍微回退一下。我之前提到过,虽然我们可以将温度作为一个变量来处理,但该变量仍然需要同时赋值value
和scale
。所以,我们仍然面临创建温度变量是一个两步过程的问题:一个赋值给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
构造函数接受value
和scale
,并使用传递的参数值来设置两个类成员变量。现在我们可以实例化我们的Temperature
,并一次性设置Value
和Scale
。
Dim temp As New Temperature(96.5, TemperatureScale.Fahrenheit)
Value
和Scale
仍然是类的public
成员,这意味着使用我们类的程序员仍然可以直接修改它们。我想阻止他们直接修改value
或scale
而留下另一个不变。虽然剥夺用户使用我们对象的灵活性可能显得奇怪,但这正是我们应该做的。
灵活性应该是我们刻意在代码中构建的东西。它不应该仅仅因为我们没有认真考虑就自动发生。更多的灵活性意味着更多的使用对象的方式,以及更多的组合对象的方式。所有这些都会导致更多的潜在测试用例和更多的细微bug。最好从暴露尽可能少的功能开始,并根据需要添加更多功能。
如果你深入研究这个概念,你会惊讶于你的类中可以隐藏多少行为,不让外部世界知道。试试看,当你认为你已经隐藏了所有可以隐藏的东西后,再仔细看看。
现在,我们有了一种让我们的结构消费者在实例化Temperature
时设置value
和scale
的方式。我们知道我们希望构建一种机制,用于在不同温度单位之间进行转换。目前我们不需要直接访问value
和scale
。所以,让我们移除这个选项;如果需要,我们随时可以重新添加它。
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
变量的值和单位。之前,我们将value
和scale
设为只读,以防止我们的类的消费者修改它们。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
。其他方法如ToLower
和ToUpper
的工作方式完全相同。这是有道理的。如果你改变了一个string
,它就不再是同一个string
了;根据定义,你正在处理一个全新的string
。
所以,一个Value Object
是在其构造函数中被赋值,并且在此之后不允许再次修改这些值的对象。任何转换对象的函数都应该返回该对象的一个全新实例。
在我们的Temperature
示例中,我们仍然可以使用我们的ConvertTo
函数,但它不应该修改温度的value
和scale
,而是应该返回一个具有适当value
和scale
的全新的Temperature
对象。
Dim temp As New Temperature(96.5, TemperatureScale.Fahrenheit)
temp = temp.ConvertTo(TemperatureScale.Fahrenheit)
这是“怎么做”;现在,让我们看看“为什么”。我们需要承认的第一件事是,Value Object
捕捉了现实世界的一个方面。像温度这样的值没有身份。如果一个温度从20度升高到30度,它就不再是同一个温度了。我们不认为数字6是数字2的修改版本。它们是不同的数字。
像Customer
或Employee
这样的实体可以以各种方式改变,但仍然是同一个Customer
或Employee
。当我们使用Value Object
时,我们就向阅读我们代码的任何人发送了一条消息。我们强调了值和实体之间的区别。
Value
对象也是通过减少选项来简化事情的另一个例子。如果我们确定一个对象不会改变,这会给我们信心,缩小可能出错的范围,并使调试更容易。
要看到这一点,请考虑当我们像参数一样将一个对象传递给一个例程时会发生什么。如果我们传递一个参数 ByRef
,我们就接受它可以被改变;而传递 ByVal
的参数则不能被改变。当我们传递对象时,这些规则实际上并不适用。
当我们传递一个对象时,我们实际上传递的是该对象的地址。这意味着 ByRef
和 ByVal
的工作方式可能不像我们期望的那样。传递 ByRef
的对象地址可以被改变,这意味着当例程结束时,作为参数传递的变量可能指向完全不同的对象实例。
传递 ByVal
的对象地址不能被改变,这意味着当例程结束时,参数仍然指向同一个对象实例。例程仍然可以做一些改变对象状态的事情,所以传递 ByVal
并不能保证我们的对象保持不变。
如果我们实现一个对象为Value Object
,那么我们就知道它不会被修改。ByVal
和ByRef
的行为与预期一致。一个传递 ByVal
的Value Object
无论例程做什么都不会改变。一个传递 ByRef
的Value 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
对象的内容。