[关闭]
@pockry 2016-01-08T13:32:29.000000Z 字数 8697 阅读 1843

Swift中的let和var背后的编程模式

移动 语言 Swift 编程模式

作者:郭麟


简介

Swift中有两种声明“变量”的方式,这两种方式分别使用letvar这两个关键字。这应该是借鉴了Scala,因为它们和Scala的valvar有相同的作用。let被用于声明不变量,var被用于声明变量。不变量的值一旦被定义就不能再改变,变量则可以在声明之后被随意赋值。

在其它一些如Java,C这样的命令式编程语言中也有不变量的概念。但多数情况下会被以常量形式使用,常量是静态的不变量。在Java中,通常用staticfinal一起来定义常量,其中static用于指明其是静态的,final用于指明其是不变的。Java中,我们有多种定义常量的方法:接口中定义,类中定义,使用枚举实现。这些方法之间的区别是在何时何地如何使用staticfinal。Objective-C,则和C语言一样,使用const关键字说明一个变量不应被改变。

在这类语言中,不变量和变量相比,通常是不寻常的,次一等的概念。如果将一个名字关联到一个值,缺省的会得到一个变量,而不是不变量。如果,你需要一个不会改变,一直和某个特定值绑定的名字,就需要显式说明它是不变的。例如,在Java中使用final,在C中使用const。这种缺省就是变量的情况,甚至影响了我们的语言。当我们需要描述,“声明用于和某个值关联的名字”时,我们说的是“声明变量”。但其实,这个“变量”应该加上引号,因为它其实可能是个不变量。这和指代不明确性别人时,使用“他”而不是“她”是同一类现象。

“缺省的是变量,如果需要不变量,请显式说明”。这是大多数命令式编程语言对变量和不变量的处理方法。这很自然。因为这类语言的设计中,大多数情况下使用的是变量,不变量只是在特殊情况下才需要。Swift(和Scala一样)则对这种设计做出了修改。从缺省是变量,转变为认为变量和不变量的地位是平等的。不变量应该更多被提倡和使用。在Swift的语法中,对这种设计思想的体现是:在定义一个和值关联的名字时,需要明确地使用varlet说明它是变量还是不变量。

Swift,和Java,C,Objective-C等语言相比,为何会有这种对待不变量的观点的变化呢?

变量和不变量其实源于两种不同编程范式。编程范式是编程语言设计者所持有的“世界观”的反映。

变量来源于命令式编程范式。这种编程范式将世界视为一系列独立的对象的组合,这些对象的行为可能会随着时间变化而不断变化。程序语言中的变量被用于模拟对象的状态。

不变量来源于函数式编程范式。这种编程以数学函数为建模核心。试图将世界抽象成为以一系列数学函数。数学函数中的变量其实和命令式编程语言中的变量存在着显著的区别。基于数学的函数式编程中的变量的概念更接近于命令式编程中的不变量。这在后续章节会详细讨论。

我们甚至可以通过对变量的态度来定义命令式编程和函数式编程:广泛采用赋值的程序设计被称为命令式程序设计;不使用任何被赋值的程序设计被称为函数式程序设计。这是因为,赋值操作使得变量可变。没有赋值操作,则变量不可变。

Swift受到了函数式编程的影响,强化了不变量在语言中位置,鼓励不变量的使用。

函数式编程中的变量

函数式编程以数学函数为建模基础。其变量的概念和数学中变量的概念是一致的。所以,我们可以先回顾一下数学函数中变量的概念。由于现在绝大多数程序设计语言是命令式的,所以我们通常所说的变量是命令式编程中的定义,这和数学函数中的变量并不相同。

在数学中,函数是描述每个输入值对应唯一输出值的这种对应关系。数学函数中的变量是一个用于表示值的符号,值是可以是随意的,也可能是未定的。所以,在数学函数中,某个符号我们之所以称其为变量,是因为它可以用于代表不同的值。而需要指明的是:当我们用明确的数值代入函数运算时,变量就拥有了明确的值。而在一次代换过程中,变量一旦被代换为明确的值,就不会再次改变为其它值。数学函数中不存在这种情况:某一次代换过程中,某个变量x一开始被代换为2,然后又变为3。这在数学上,没有任何意义。

这样看起来,数学函数中的变量其实应该可以对应程序语言中的不变量:一旦被定义,就不再变化。纯粹的函数式编程语言就完整继承了这种数学上的变量概念。例如,Haskell就没有可变量的概念,声明一个变量,只能被赋值一次,之后就不会再变化。而命令式编程语言中,变量被定义之后,仍然能够随意被赋予其它的值。

比如我们有一个简单的数学函数:

  1. f(x) = 2*x + x * x

如果,我们遵循数学函数对变量的看法,可以将其翻译为如下的Swift函数。这个程序函数和上面的数学函数,在概念上是等价的。

  1. func foo(x: Int) -> Int {
  2. return 2*x + x * x
  3. }

当然,这个Swift函数foo,还有其它现实方法。函数的另外一种实现bar为了展示y是一个命令式编程里的变量,而稍显怪异。但它仍然能得到和上面的函数相同的答案:代入任意相同的x值,两个函数都会得到相同的返回值。但由于数学函数中不存在y这样的一开始等于某个值,而后又被赋为另一个值这样的命令式编程中的变量概念。所以,我们没有办法将下面这样的Swift函数bar还原为一个概念上一致的数学函数。

  1. func bar(x: Int) -> Int {
  2. var y = 2 * x
  3. y = y + x * x
  4. return y
  5. }

Swift中提供let声明不变量,更为重视不变性,明确鼓励在更多的场合使用不变量。这都是受函数式编程中变量的不变性的影响。后面会讨论Swift为何会受到这种影响。

命令式编程中的变量

命令式编程语言中的变量的概念为大多数程序员所熟悉。我们将其和函数式编程中的变量做一个对比:在函数式编程中,变量其实并不可变,这种变量只是一个代表了某个值的符号。而在命令式编程中,由于变量是可变的,变量就不仅仅是简单代表一个值的符号,而是索引了一个可以保存值的位置,在这个位置上可以存放不同的值。

我们的世界中每个对象都有着自己随着时间变化的状态。而在不同时刻,变量可以代表了不同的值,使得变量拥有了时序上的概念。我们就可以使用变量来模拟和刻画现实世界中的对象的状态。这其实也是为何会引入赋值,使得变量可变的原因。

引入赋值的好处

如果使用过一些函数式编程语言,就会发现部分函数式编程语言并没有完全抛弃赋值。在Scheme中,我们仍然可以用(set! x 15)这样的语句为变量赋值,变量将在赋值前后和不同的值关联。为何这些函数式编程语言没有完整地贯彻变量的不变性呢?

函数式编程语言出现的时间很早,最早的函数式编程语言Lisp是历史第二悠久的高级编程语言(仅次于Fortran)。但现在函数式编程语言并没有成为绝大多数程序员的工作语言。为何现今流行的编程语言:C,Java,C++,Python都是命令式编程语言呢?

这是因为,引入赋值,使得变量可变。就引入了一个简单直观又易于模块化的程序语言建模方法。这在设计大型软件系统时是一个巨大的优势。

命令式编程的建模思想是一种直观的世界观:“世界是由聚集在一起的一系列独立的对象组成的”。但这仅仅是在一个维度上的描述。另外一个时间维度上的描述通常不被提及:“每个对象都有着随时间变化的状态”。综合来说就是:“世界由对象组成,对象都有状态”。将这种直观的世界观引入程序设计所带来的好处是,建模更为简单了。使用这种思想的编程语言对于程序员来说也更为简单直观了。那么将一个实际问题用这种编程语言中的概念来描述,也就变得更轻松了。因为,程序员通常能够为实际问题中的事物一一对应地构建对象,并按时序描述每个对象的状态。

如果将赋值和局部变量结合,构造带局部状态的对象,就可以提供一种有利于系统模块化设计的技术。这是一种强大的设计策略,原因在于它的简单和直观。我们可以直接构造那些用于模拟真实物理系统的对象。对于问题域里的每个对象,我们都可以构造一个与之相对应的计算机程序里的对象。如果,我们能把对象的“状态”局限在对象内部,使之成为“局部状态”(这其实就是封装)。然后,将各自具有“局部状态”的对象组合,这会是一个良好的模拟真实世界的手段。

我们之所以可以使用UML(Unified Modeling Language)来分析项目需求,是因为我们将在项目中使用命令式编程语言。从UML这种图形化的辅助建模方式中,我们可以更明显地看到如何将真实世界中的对象和程序语言中的对象一一对应,如何将真实世界中的对象的一个个属性和程序语言中的对象的变量一一对应。

如果,使用函数式编程语言,UML将不再能起到任何作用。你需要的是一个类似将现实问题抽象为数学问题的过程。这种数学的建模方式对大多数人来说可能都会更为困难一些。

引入赋值的代价

在函数式编程中引入赋值,存在着一些争议。仍然有如Haskell这样的函数式编程语言,坚持纯粹的函数式编程思想,不使用任何赋值操作(当然,仍然有使用不变量难以描述的情况存在。Haskell社区称这部分为有副作用的,不纯的。这部分代码会被限制在Monad中实现)。

也有Swift和Scala这样的新兴语言,重新思考函数式编程语言中不变性的意义。在语言设计中,强调和重视不变性。

这是因为没有免费的午餐。引入赋值,除了上节所说的带来了一个简单直观又易于模块化的程序语言建模方法之外,也引入了一些缺陷,我们需要为此付出一些代价。其中一些缺陷使得我们在构建大规模软件系统时,遇到了一些难以克服的困难。

更复杂的计算模型

为函数式编程语言引入赋值语句,使得变量可变。看起来只是多了赋值语法,但其实这并不是一件简单的事情。赋值的引入对编程语言造成的影响是巨大的:随着赋值的引入,我们必须为编程语言引入一种更为复杂的计算模型。

在没有赋值语句之前,纯函数式编程语言可以使用数学上的代换模型来构建语言的计算模型:一个变量可以安全地被代换为它所代表的表达式或者值。求值一个纯函数式编程语言中的函数,和求值一个数学函数并没有什么区别。你可以认为编程语言的运行方式和数学的运算方式是一样的。这种代换模型其实是一个相当简单的语言模型。

但在引入赋值之后,变量在程序运行的某些时刻代表一个值,在另一些时刻代表另外一个值。代换模型就不再有效了。因为,代换模型基于数学模型。数学上并没有在某些时刻代表一个值,在另一些时刻代表另外一个值的变量概念。如果尝试对带有赋值操作的函数进行代换,会发现当遇到赋值语句时,代换过程无法进行下去。因为变量已经不能被再被看做是某个值的名字了。此时的变量以某种方式指定了一个“位置”,我们可以将任何值存储在该“位置”。那到底是将哪个值代入变量呢?在代换模型中,无法解决该问题。

为了解决这个问题,我们引入更为复杂的环境模型。变量将维持在我们称为“环境”的结构中。环境包含一系列约束,这些约束将一些变量的名字关联到对应值。在环境模型中,变量的值将取决于其所处的环境。程序运行过程中,环境时常变化,变量的值也就随之改变。

引入更复杂的计算模型意味着实现编程语言变得更为困难了。

同一问题的复杂化

相等的判断

我们抛开具体的程序语言讨论一下如何判断对象相等。在程序语言中,有一种从效果上判断相同的方法:如果在任意计算中用一个对象替换另外一个对象,都不会改变结果,那么我们就可以认为这两个对象相等。

如果,没有赋值操作存在。我们判断对象相等会简单一些。例如,在下面例子中,let使Point的实例变量xy都成为不变量。p1p2xy相等,而且两个点的x,y值都不会改变。所以,可以认为在任何时候的任何计算中,p1p2都是可以相互替换的。我们就可以认为p1p2相等。

  1. struct Point {
  2. let x: Double
  3. let y: Double
  4. }
  5. let p1 = Point(x: 1, y: 2)
  6. let p2 = Point(x: 1, y: 2)

但是,如果我们使用var来声明Point的实例变量。下面例子中的p1p2相等的结论就不一定正确了。因为,我们可以使用赋值操作来改变点的实际坐标了。当执行p1.x = 2之后,显然它们就无法在任何计算中相互替换了。我们不能认为p1p2相等了。

  1. struct Point {
  2. var x: Double
  3. var y: Double
  4. }
  5. var p1 = Point(x: 1, y: 2)
  6. var p2 = Point(x: 1, y: 2)

可以看到在引入赋值之后,判断两个对象是否相等的问题变得更为复杂了。

别名

在拥有赋值操作后,另外一个经常引起困惑和错误的是别名问题。一个对象可以通过多个名字访问的的现象称为别名。下面展示了一个别名的最简单的例子:

  1. class Point {
  2. var x: Double
  3. var y: Double
  4. init(x: Double, y: Double) {
  5. self.x = x
  6. self.y = y
  7. }
  8. }
  9. var p1 = Point(x: 1, y: 2)
  10. var p2 = Point(x: 1, y: 2)
  11. var p3 = p1
  12. p3.x = 2

上面代码中,p1p2是两个独立对象,p3p1的别名。这两组关系之间有微妙的区别,我们常常在实际编程过程中混淆两者。p1p2对各自的修改互不影响,可以认为它们是两个独立的点。而p1p3可以认为是一个点。对其中任何一个的修改都会造成另一个也同样被修改。如果,我们想在程序中搜索出p1可能被修改的地方,就必须记住,也要检查那些修改了p3的地方。然而在实际编程中,特别是在大型复杂系统中,我们常常会忘记,或者根本就不知道p3是某个对象(这里是p1)的别名。要么,修改了p3,却不知道也造成了p1的修改。这种副作用常常防不胜防,在编程中经常出现。要么,在需要对修改操作做重新设计时,只顾及了p3,而忘记同时也要修改p1的地方。这种别名常常难以被识别而被遗忘。

但是,如果没有赋值操作,别名造成的困扰就消失了。即使在实际物理内存上,这两组关系并不相同:p1p2指向两块不同的内存地址,p1p3指向同一块内存地址。但你仍然可以认为p1p2p3是相等的对象。因为,在没有赋值的情况下,它们在任何计算中都可以相互替换。是否是别名在计算中并没有什么区别。

值类型和引用类型

也许有人发现:开始,我们使用结构体(struct)实现Point,而后在解释别名问题时又改用类(class)实现Point。这是因为Swift扩大了值类型的使用范围。

在Java中,可以认为原始类型(int,long,float,double,short,char,boolean)是值类型,而其它继承自Object的类型都是引用类型。

而在Swift中,结构体被设计成一种值类型。整数,浮点数,布尔值,字符串,数组和字典在Swift中都是以结构体的形式实现的,所以,它们也都是值类型。特别是数组,字典这种常用集合类型也被实现为值类型,使得值类型在Swift中的使用范围大大扩展了。

值类型在被赋给一个变量,或者被传递给函数时,实际上是做了一次拷贝。与值类型对应是引用类型。引用类型在被赋给一个变量,或者被传递给函数时,是传递的是引用。类(class)仍然是引用类型。所以,类实现的Point会有别名的问题。而值类型不会有这类别名所带来的问题。

在下面用结构体实现Point的例子中,p3不再是p1的别名,而是p1的一个拷贝。

  1. struct Point {
  2. var x: Double
  3. var y: Double
  4. }
  5. var p1 = Point(x: 1, y: 2)
  6. var p2 = Point(x: 1, y: 2)
  7. var p3 = p1

我们可能会问一个问题:如果每次赋值都进行拷贝,是否会大大增加内存开销呢?如果每次赋值都进行对象拷贝,确实会增大内存开销。Swift的解决方案是:只在值类型发生改变时才进行拷贝。就上面的结构体实现的Point的例子而言,var p3 = p1虽然进行了赋值,但这时还并没有发生拷贝操作。这时,p3其实仍然是p1的别名,它们指向同一个内存地址。直到我们改变p3了,比如执行p3.x = 2时,才会先发生拷贝,然后在拷贝的副本上进行赋值修改操作。这么做当然节省了内存开销。而可以这么做的根据是:没有赋值操作时,同一问题更简单了,别名并不会带来问题。在这种没有赋值的情况下,值类型和引用类型其实可以被认为是等效的。

扩大值类型的使用范围是Swift减缓别名问题的一种方式。另外一种方式,则是我们在本文中一直讨论的:由于赋值操作的引入,使得同一问题复杂化了。那么,即使现在做不到完全去除赋值操作,一定程度上鼓励不变性,在需要的环境中使用不变量,也能缓解这种复杂性所带来的问题。

赋值顺序

可以举一个求阶乘的例子来说明,赋值语句的相对顺序对结果的影响。

  1. func factorial(n: Int) -> Int {
  2. var product = 1
  3. var i = 1
  4. while i <= n {
  5. product = i * product
  6. i = i + 1
  7. }
  8. return product
  9. }

这个例子中,如果我们将product = i * producti = i + 1两条语句的执行顺序互换,将会得到不同的结果。一般而言,带有赋值的程序将强迫程序员考虑赋值的相对顺序,以保证每个语句所用的是被修改变量的正确版本。这增加了程序员的负担。使得程序员每次用到赋值时,都需要清楚变量的赋值操作之间的相对顺序。

函数式编程语言中,由于没有赋值,所以根本没有这类问题。为了对比,下面例子使用函数式编程的风格再次实现阶乘。在函数式编程中,一般会使用递归来代替命令式编程中所用到的循环结构。这样风格的代码中,我们无法体会到对于时序的要求。

  1. func factorial(n: Int) -> Int {
  2. if n == 0 {
  3. return 1
  4. }
  5. return n * factorial(n - 1)
  6. }

并发问题

在单线程环境中,考虑赋值操作的相对顺序对程序运行结果正确性的影响,仍然可以算是一个相对简单可控的问题。但如果是在多线程环境中,就会延伸出一些更严重的问题。

我们考虑一个简单银行账户系统,并考虑一下并发存款或者取款的情形:

  1. class account {
  2. var balance: Double
  3. init(balance: Double) {
  4. self.balance = balance
  5. }
  6. func withdraw(amount: Double) {
  7. let newBalance = self.balance - amount // #1
  8. self.balance = newBalance // #2
  9. }
  10. func deposit(amount: Double) {
  11. let newBalance = self.balance + amount
  12. self.balance = newBalance
  13. }
  14. }
  15. let george = account(balance: 100)
  16. let paul = george
  17. george.withdraw(10)
  18. paul.withdraw(20)

这个例子中,可以认为Paul和George共享了一个银行账户。George和Paul在不同的地方同时取款。这种情况我们可以在两个并发线程中分别执行george.withdraw(10)paul.withdraw(20)来模拟。我们有可能会得到错误的余额结果,这对银行来说可能不是好事。

如果出现以下执行顺序,情况就不太美妙:

这当然是错误的结果,余额最开始为100元,George取了10元,Paul取了20元,余额应该是70元。银行因为这个并发错误亏损了10元。仔细查看以上过程,可以发现错误发生在Paul将余额更新为80元时,其实存在一个前提:更新之前余额应该是100元。但不幸的是在George将余额修改为90元之后,上述前提已再不合法。更不幸的是,在实际情况中,这类错误并不是每次都会发生。这取决于各个线程以何种顺序执行代码。而这种不能稳定复现的错误,常常难以修复。

这个错误也揭示了,时间在程序中所产生的影响。计算结果需要依赖各个赋值发生的顺序。并发情况下,正确地控制这种顺序变得更加复杂了。

很多工具和并发控制策略被发明出来用于解决并发问题:原子操作,阻塞,信号,锁。但这些工具和策略仍然很复杂,让程序员掌握这些工具并不容易,有些还会影响程序的运行效率。而且例如死锁这样的问题,即使引入复杂的死锁避免技术,在一些地方也仍然无法完全避免。

引入赋值之前,程序没有时间的问题,变量任何时候具有某个值,将总是具有这个值。引入赋值之后,我们就必须开始考虑时间在计算中的作用。在并发情况下,由赋值引入的复杂性变得更加严重了。需要在程序中考虑时间的作用的负担变得越来越严重了。

时至今日,要编写线程安全的,且性能可靠的并发环境下执行的程序,对命令式编程语言来说,仍然是严峻的考验。这个问题直接促使Swift,Scala这样的新兴语言开始从函数式编程语言中寻找灵感,来解决或者缓解并发问题。

总结

Swift中有两个声明变量的关键字:letvar。这两个关键字背后存在着两种截然不同的编程思想:函数式编程和命令式编程。Swift对这两种编程思想进行了融合:它允许你使用引入赋值所带来的简单直观的建模方法。同时也鼓励你使用不变性缓解各类并发问题。

参考文档

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注