进出原则

在大多数现代 OOP(面向对象编程)语言(如 Java 和 C#)中,封装(encapsulation)和信息隐藏并未严格执行。另一方面,在 Smalltalk 中 - 最著名和最有影响力的 OOP 语言 - 封装和信息隐藏是基本原则:如果将变量 x 发送到方法 y,并且在 y 内部更改 x 的值,则无法从方法外部获取 x 的更改值 - 除非方法显式返回该值

“封装”(Encapsulation)或“信息隐藏”(Information Hiding)?

通常这两个术语可互换使用。但是,要进行挑选的话,则是存在差异的。封装(Encapsulation)是指将对象的“状态”(state,其数据)与可能改变或询问其状态(方法)的操作组合在一起。信息隐藏(Information Hiding)是指数据被封锁并且只能使用明确定义的路径进出访问这一事实 - 在面向对象的术语中,这意味着获取或返回值的“访问器方法”(accessor methods)。在面向过程语言中,信息隐藏可能采取其它形式 - 例如,你可能必须定义接口以从代码“单元”或“模块”而不是对象中检索数据。在 OOP 术语中,封装和信息隐藏几乎是同义词 - 真正的封装必然意味着隐藏了对象的内部数据。但是,许多现代的“OOP语言”,例如 Java,C#,C++ 和 Object Pascal,在强制执行信息隐藏的程度上(如果有的话)是非常宽容的。

通常,Ruby 遵循这个原则:参数进入方法,除非 Ruby 返回更改的值,否则无法从外部访问方法内的对该参数的任何更改:

hidden.rb
  1. def hidden( aStr, anotherStr )
  2. anotherStr = aStr + " " + anotherStr
  3. return aStr + anotherStr.reverse
  4. end
  5. str1 = "dlrow"
  6. str2 = "olleh"
  7. str3 = hidden(str1, str2) # str3 receives returned value
  8. puts( str1 ) # input args: original values unchanged
  9. puts( str2 )
  10. puts( str3 ) # returned value ( "dlrowhello world" )

事实证明,有时候传递给 Ruby 方法的参数可以像其它语言的’引用’(by reference)参数一样使用(也就是说,在方法内部进行的更改可能会影响方法之外的变量)。这是因为某些 Ruby 方法修改了原始对象,而不是生成值并将其分配给新对象。

例如,一些以感叹号结尾方法会修改原始对象。类似的,字符串的附加(append)方法 << 将其右侧的字符串与左侧字符串连接起来的过程中没有创建新的对象:因此左侧字符串的值被修改,但字符串对象本身保留其原始的 object_id

这样做的结果是,如果在方法中使用 << 运算符而不是 + 运算符,则结果将更改:

not_hidden.rb
  1. def nothidden( aStr, anotherStr )
  2. anotherStr = aStr << " " << anotherStr
  3. return aStr << anotherStr.reverse
  4. end
  5. str1 = "dlrow"
  6. str2 = "olleh"
  7. str3 = nothidden(str1, str2)
  8. puts( str1 ) # input arg: changed ("dlrow ollehhello world")
  9. puts( str2 ) # unchanged
  10. puts( str3 ) # returned value("dlrow ollehhello world")
str_reverse.rb

str_reverse.rb 示例程序应该有助于解释清楚这一点。例如,这表明当你使用 reverse 方法时,不会对’接收对象’(receiver object,即 str1 这样的对象:str1.reverse)进行更改。但是当你使用 reverse! 方法对对象进行更改(使其字母顺序反转)。即便如此,也没有创建新对象:str1 是调用 reverse! 方法前后的同一个对象。

这里 reverse 像大多数 Ruby 方法一样运行 - 它产生一个值,为了使用该值,你必须将它分配给一个新对象。所以…

  1. str1 = "hello"
  2. str1.reverse

这里,str1 不受调用 reverse 的影响。它仍然具有值 ‘hello’,它仍然具有原始的 object_id。但是…

  1. str1 = "hello"
  2. str1.reverse!

这次,str1 改变了(变成了,’olleh’)。即便如此,也没有创建新对象:str1 具有与之前相同的 object_id。然后再次…

  1. str1 = "hello"
  2. str1 = str1.reverse

这次,str1.reverse 产生的值被分配给 str1。生成的值是一个新对象,因此 str1 现在被分配了反向字符串(’olleh’),现在它有一个新的 object_id

有关字符串连接方法 << 的示例,请参阅示例程序 concat.rb,像那些以 ! 结尾的方法一样,会修改接收对象(receiver object)而不创建新对象:

concat.rb
  1. str1 = "hello"
  2. str2 = "world"
  3. str3 = "goodbye"
  4. str3 = str2 << str1

在这个示例中,str1 永远不会被修改,所以它始终具有相同的 object_idstr2 通过连接操作被修改。

但是,<< 运算符不会创建新对象,因此 str2 也会保留其原始 object_id。但 str3 最后是一个与开始不同的对象:这是因为它被赋值由这个表达式产生的值:str2 << str1。这个值恰好是 str2 对象本身,因此 str3object_id 现在与 str2object_id 相同(即 str2str3 现在引用相同的对象)。

总之,以 ! 结尾的方法,比如 reverse!,再加上一些其它方法,比如 << 连接方法,会改变接收者对象(receiver object)本身的值。大多数其它方法不会修改接收对象的值,并且为了利用因调用方法而产生的任何新值,你必须将该值赋给变量(或将生成的值作为参数传递给一个方法)。

修改接收对象违背封装原则

大多数方法看起来不是足够无害的,但事实上只有极少一部分方法会修改接受对象(receiver object)。但请注意:这种行为使你能够通过引用而不是显式的返回值来重新获取参数值。通过允许你的代码依赖于方法内部的实现细节,这样做会破坏封装(encapsulation)原则。这可能会导致不可预测的副作用,在我看来,应该避免这么做。
side_effects.rb

这是一个依赖于修改的参数值而不是显式的返回值,可能会对实现细节引入一些不必要的依赖关系的简单(但在实际编程中,可能是严重的)示例,请参阅 side_effects.rb。这里我们有一个名为 stringProcess 的方法,它接受两个字符串参数,将它们混淆并返回结果。假设练习的对象接收两个小写字符串并返回一个单独的字符串,该字符串会以空格分隔并且首字母和最后一个字母大写的方式组合这两个字符串。所以两个原始字符串可能是 “hello” 和 “world”,返回的字符串是 “Hello hellD”。

但现在我们有一个不耐烦的程序员,他不想使用返回值。他注意到在方法中进行的修改会改变进入参数的值。他注意到在方法中进行的修改会改变进入参数的值。哎呀!(他决定)他不妨使用参数自己来实现!然后他编写了一个非常复杂的文本处理系统,其中有数千块代码依赖于这两个参数被修改的值。

但是最初编写 stringProcess 方法的程序员现在觉得原来的实现是低效且不优雅的,因此决定重写代码,确信返回的值也不会变(如果 “hello” 和 “world” 作为参数发送,”Hello worlD” 将被返回)。

啊哈!但是新实现会导致输入参数的值在方法体内会被更改。所以,这位没有耐心的程序员的这个依赖于这些输入参数而不是返回值的文本处理系统,现在输入这些参数时会输出 “hello Dlrow”,而不是他期望的 “Hello worlD”(实际上,他的程序在处理莎士比亚(Shakespeare )的作品时,最终划时代的艺术家会宣称:”Toeb or ton to eb, that si the noitseuq…”)。这是一种意想不到的副作用,可以通过遵循单向输入(one-way-in)和单向输出(one-way-out)原则轻松避免。