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

jopen 8年前發布 | 37K 次閱讀 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就沒有可變量的概念,聲明一個變量,只能被賦值一次,之后就不會再變化。而命令式編程語言中,變量被定義之后,仍然能夠隨意被賦予其它的值。

比如我們有一個簡單的數學函數:

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

如果,我們遵循數學函數對變量的看法,可以將其翻譯為如下的Swift函數。這個程序函數和上面的數學函數,在概念上是等價的。

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

當然,這個Swift函數 foo ,還有其它現實方法。函數的另外一種實現 bar 為了展示 y 是一個命令式編程里的變量,而稍顯怪異。但它仍然能得到和上面的函數相同的答案:代入任意相同的 x 值,兩個函數都會得到相同的返回值。但由于數學函數中不存在 y 這樣的一開始等于某個值,而后又被賦為另一個值這樣的命令式編程中的變量概念。所以,我們沒有辦法將下面這樣的Swift函數 bar 還原為一個概念上一致的數學函數。

func bar(x: Int) -> Int {
    var y = 2 * x
    y =  y + x * x
    return y
}

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 相等。

struct Point {
  let x: Double
  let y: Double
}

let p1 = Point(x: 1, y: 2) let p2 = Point(x: 1, y: 2)</pre>

但是,如果我們使用 var 來聲明 Point 的實例變量。下面例子中的 p1p2 相等的結論就不一定正確了。因為,我們可以使用賦值操作來改變點的實際坐標了。當執行 p1.x = 2 之后,顯然它們就無法在任何計算中相互替換了。我們不能認為 p1p2 相等了。

struct Point {
  var x: Double
  var y: Double
}

var p1 = Point(x: 1, y: 2) var p2 = Point(x: 1, y: 2)</pre>

可以看到在引入賦值之后,判斷兩個對象是否相等的問題變得更為復雜了。

別名

在擁有賦值操作后,另外一個經常引起困惑和錯誤的是別名問題。一個對象可以通過多個名字訪問的的現象稱為別名。下面展示了一個別名的最簡單的例子:

class Point {
  var x: Double
  var y: Double

init(x: Double, y: Double) { self.x = x self.y = y } }

var p1 = Point(x: 1, y: 2) var p2 = Point(x: 1, y: 2) var p3 = p1 p3.x = 2</pre>

上面代碼中, 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 的一個拷貝。

struct Point {
  var x: Double
  var y: Double
}

var p1 = Point(x: 1, y: 2) var p2 = Point(x: 1, y: 2) var p3 = p1</pre>

我們可能會問一個問題:如果每次賦值都進行拷貝,是否會大大增加內存開銷呢?如果每次賦值都進行對象拷貝,確實會增大內存開銷。Swift的解決方案是:只在值類型發生改變時才進行拷貝。就上面的結構體實現的 Point 的例子而言, var p3 = p1 雖然進行了賦值,但這時還并沒有發生拷貝操作。這時, p3 其實仍然是 p1 的別名,它們指向同一個內存地址。直到我們改變 p3 了,比如執行 p3.x = 2 時,才會先發生拷貝,然后在拷貝的副本上進行賦值修改操作。這么做當然節省了內存開銷。而可以這么做的根據是:沒有賦值操作時,同一問題更簡單了,別名并不會帶來問題。在這種沒有賦值的情況下,值類型和引用類型其實可以被認為是等效的。

擴大值類型的使用范圍是Swift減緩別名問題的一種方式。另外一種方式,則是我們在本文中一直討論的:由于賦值操作的引入,使得同一問題復雜化了。那么,即使現在做不到完全去除賦值操作,一定程度上鼓勵不變性,在需要的環境中使用不變量,也能緩解這種復雜性所帶來的問題。

賦值順序

可以舉一個求階乘的例子來說明,賦值語句的相對順序對結果的影響。

func factorial(n: Int) -> Int {
  var product = 1
  var i = 1
  while i <= n {
    product = i * product
    i = i + 1
  }

return product }</pre>

這個例子中,如果我們將 product = i * producti = i + 1 兩條語句的執行順序互換,將會得到不同的結果。一般而言,帶有賦值的程序將強迫程序員考慮賦值的相對順序,以保證每個語句所用的是被修改變量的正確版本。這增加了程序員的負擔。使得程序員每次用到賦值時,都需要清楚變量的賦值操作之間的相對順序。

函數式編程語言中,由于沒有賦值,所以根本沒有這類問題。為了對比,下面例子使用函數式編程的風格再次實現階乘。在函數式編程中,一般會使用遞歸來代替命令式編程中所用到的循環結構。這樣風格的代碼中,我們無法體會到對于時序的要求。

func factorial(n: Int) -> Int {
  if n == 0 {
    return 1
  }
  return n * factorial(n - 1)
}

并發問題

在單線程環境中,考慮賦值操作的相對順序對程序運行結果正確性的影響,仍然可以算是一個相對簡單可控的問題。但如果是在多線程環境中,就會延伸出一些更嚴重的問題。

我們考慮一個簡單銀行賬戶系統,并考慮一下并發存款或者取款的情形:

class account {
  var balance: Double

init(balance: Double) { self.balance = balance }

func withdraw(amount: Double) { let newBalance = self.balance - amount // #1 self.balance = newBalance // #2
}

func deposit(amount: Double) { let newBalance = self.balance + amount self.balance = newBalance }

}

let george = account(balance: 100) let paul = george

george.withdraw(10) paul.withdraw(20)</pre>

這個例子中,可以認為Paul和George共享了一個銀行賬戶。George和Paul在不同的地方同時取款。這種情況我們可以在兩個并發線程中分別執行 george.withdraw(10)paul.withdraw(20) 來模擬。我們有可能會得到錯誤的余額結果,這對銀行來說可能不是好事。

如果出現以下執行順序,情況就不太美妙:

  • 首先,George執行完了 #1 語句,得到了 newBalance 的值為90。
  • 同時,Paul在另外一個線程中也執行完了 #1 語句,得到了 newBalance 的值為80。
  • 然后,George執行 #2 語句,用 newBalance 為90更新了 self.balance ,余額減為90元。
  • 最后,Paul執行 #2 語句,他悲劇地以值為80的 newBalance 更新了 self.balance ,余額最終被更新為80元。

這當然是錯誤的結果,余額最開始為100元,George取了10元,Paul取了20元,余額應該是70元。銀行因為這個并發錯誤虧損了10元。仔細查看以上過程,可以發現錯誤發生在Paul將余額更新為80元時,其實存在一個前提:更新之前余額應該是100元。但不幸的是在George將余額修改為90元之后,上述前提已再不合法。更不幸的是,在實際情況中,這類錯誤并不是每次都會發生。這取決于各個線程以何種順序執行代碼。而這種不能穩定復現的錯誤,常常難以修復。

這個錯誤也揭示了,時間在程序中所產生的影響。計算結果需要依賴各個賦值發生的順序。并發情況下,正確地控制這種順序變得更加復雜了。

很多工具和并發控制策略被發明出來用于解決并發問題:原子操作,阻塞,信號,鎖。但這些工具和策略仍然很復雜,讓程序員掌握這些工具并不容易,有些還會影響程序的運行效率。而且例如死鎖這樣的問題,即使引入復雜的死鎖避免技術,在一些地方也仍然無法完全避免。

引入賦值之前,程序沒有時間的問題,變量任何時候具有某個值,將總是具有這個值。引入賦值之后,我們就必須開始考慮時間在計算中的作用。在并發情況下,由賦值引入的復雜性變得更加嚴重了。需要在程序中考慮時間的作用的負擔變得越來越嚴重了。

時至今日,要編寫線程安全的,且性能可靠的并發環境下執行的程序,對命令式編程語言來說,仍然是嚴峻的考驗。這個問題直接促使Swift,Scala這樣的新興語言開始從函數式編程語言中尋找靈感,來解決或者緩解并發問題。

總結

Swift中有兩個聲明變量的關鍵字: letvar 。這兩個關鍵字背后存在著兩種截然不同的編程思想:函數式編程和命令式編程。Swift對這兩種編程思想進行了融合:它允許你使用引入賦值所帶來的簡單直觀的建模方法。同時也鼓勵你使用不變性緩解各類并發問題。

參考文檔

感謝徐川對本文的審校。

給InfoQ中文站投稿或者參與內容翻譯工作,請郵件至editors@cn.infoq.com。也歡迎大家通過新浪微博(@InfoQ,@丁曉昀),微信(微信號: InfoQChina )關注我們,并與我們的編輯和其他讀者朋友交流(歡迎加入InfoQ讀者交流群 Swift中的let和var背后的編程模式 (已滿),InfoQ讀者交流群(#2) Swift中的let和var背后的編程模式 )。

來自: http://www.infoq.com/cn/articles/programming-model-behind-let-and-var-in-swift

 本文由用戶 jopen 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!