2.4 可變數據
譯者:飛龍
我們已經看到了抽象在幫助我們應對大型系統的複雜性時如何至關重要。有效的程序整合也需要一些組織原則,指導我們構思程序的概要設計。特別地,我們需要一些策略來幫助我們構建大型系統,使之模塊化。也就是說,它們可以“自然”劃分為可以分離開發和維護的各個相關部分。
我們用於創建模塊化程序的強大工具之一,是引入可能會隨時間改變的新類型數據。這樣,單個數據可以表示獨立於其他程序演化的東西。對象行為的改變可能會由它的歷史影響,就像世界中的實體那樣。向數據添加狀態是這一章最終目標:面向對象編程的要素。
我們目前引入的原生數據類型 -- 數值、布爾值、元組、範圍和字符串 -- 都是不可變類型的對象。雖然名稱的綁定可以在執行過程中修改為環境中不同的值,但是這些值本身不會改變。這一章中,我們會介紹一組可變數據類型。可變對象可以在程序執行期間改變。
2.4.1 局部狀態
我們第一個可變對象的例子就是局部狀態。這個狀態會在程序執行期間改變。
為了展示函數的局部狀態是什麼東西,讓我們對從銀行取錢的情況進行建模。我們會通過創建叫做withdraw的函數來實現它,它將要取出的金額作為參數。如果賬戶中有足夠的錢來取出,withdraw應該返回取錢之後的餘額。否則,withdraw應該返回消息'Insufficient funds'。例如,如果我們以賬戶中的$100開始,我們希望通過調用withdraw來得到下面的序列:
>>> withdraw(25)
75
>>> withdraw(25)
50
>>> withdraw(60)
'Insufficient funds'
>>> withdraw(15)
35
觀察表達式withdraw(25),求值了兩次,產生了不同的值。這是一種用戶定義函數的新行為:它是非純函數。調用函數不僅僅返回一個值,同時具有以一些方式修改函數的副作用,使帶有相同參數的下次調用返回不同的結果。我們所有用戶定義的函數,到目前為止都是純函數,除非他們調用了非純的內建函數。它們仍舊是純函數,因為它們並不允許修改任何在局部環境幀之外的東西。
為了使withdraw有意義,它必須由一個初始賬戶餘額創建。make_withdraw函數是個高階函數,接受起始餘額作為參數,withdraw函數是它的返回值。
>>> withdraw = make_withdraw(100)
make_withdraw的實現需要新類型的語句:nonlocal語句。當我們調用make_withdraw時,我們將名稱balance綁定到初始值上。之後我們定義並返回了局部函數,withdraw,它在調用時更新並返回balance的值。
>>> def make_withdraw(balance):
"""Return a withdraw function that draws down balance with each call."""
def withdraw(amount):
nonlocal balance # Declare the name "balance" nonlocal
if amount > balance:
return 'Insufficient funds'
balance = balance - amount # Re-bind the existing balance name
return balance
return withdraw
這個實現的新奇部分是nonlocal語句,無論什麼時候我們修改了名稱balance的綁定,綁定都會在balance所綁定的第一個幀中修改。回憶一下,在沒有nonlocal語句的情況下,賦值語句總是會在環境的第一個幀中綁定名稱。nonlocal語句表明,名稱出現在環境中不是第一個(局部)幀,或者最後一個(全局)幀的其它地方。
我們可以將這些修改使用環境圖示來可視化。下面的環境圖示展示了每個調用的效果,以上面的定義開始。我們省略了函數值中的代碼,以及不在我們討論中的表達式樹。

我們的定義語句擁有平常的效果:它創建了新的用戶定義函數,並且將名稱make_withdraw在全局幀中綁定到那個函數上。
下面,我們使用初始的餘額參數20來調用make_withdraw。
>>> wd = make_withdraw(20)
這個賦值語句將名稱wd綁定到全局幀中的返回函數上:

所返回的函數,(內部)叫做withdraw,和定義所在位置即make_withdraw的局部環境相關聯。名稱balance在這個局部環境中綁定。在例子的剩餘部分中,balance名稱只有這一個綁定,這非常重要。
下面,我們求出以總數5調用withdraw的表達式的值:
>>> wd(5)
15
名稱wd綁定到了withdraw函數上,所以withdraw的函數體在新的環境中求值,新的環境擴展自withdraw定義所在的環境。跟蹤withdraw求值的效果展示了 Python 中nonlocal語句的效果。

withdraw的賦值語句通常在withdraw的局部幀中為balance創建新的綁定。由於nonlocal語句,賦值運算找到了balance定義位置的第一幀,並在那裡重新綁定名稱。如果balance之前沒有綁定到值上,那麼nonlocal語句會產生錯誤。
通過修改balance綁定的行為,我們也修改了withdraw函數。下次withdraw調用的時候,名稱balance會求值為15而不是20。
當我們第二次調用wd時,
>>> wd(3)
12
我們發現綁定到balance的值的修改可在兩個調用之間積累。

這裡,第二次調用withdraw會創建第二個局部幀,像之前一樣,但是,withdraw的兩個幀都擴展自make_withdraw的環境,它們都包含balance的綁定。所以,它們共享特定的名稱綁定,調用withdraw具有改變環境的副作用,並且會由之後的withdraw調用繼承。
**實踐指南。**通過引入nonlocal語句,我們發現了賦值語句的雙重作用。它們修改局部綁定,或者修改非局部綁定。實際上,賦值語句已經有了兩個作用:創建新的綁定,或者重新綁定現有名稱。Python 賦值的許多作用使賦值語句的執行效果變得模糊。作為一個程序員,你應該用文檔清晰記錄你的代碼,使賦值的效果可被其它人理解。
2.4.2 非局部賦值的好處
非局部賦值是將程序作為獨立和自主的對象觀察的重要步驟,對象彼此交互,但是各自管理各自的內部狀態。
特別地,非局部賦值提供了在函數的局部範圍中維護一些狀態的能力,這些狀態會在函數之後的調用中演化。和特定withdraw函數相關的balance在所有該函數的調用中共享。但是,withdraw實例中的balance綁定對程序的其餘部分不可見。只有withdraw關聯到了make_withdraw的幀,withdraw在那裡被定義。如果make_withdraw再次調用,它會創建單獨的幀,帶有單獨的balance綁定。
我們可以繼續以我們的例子來展示這個觀點。make_withdraw的第二個調用返回了第二個withdraw函數,它關聯到了另一個環境上。
>>> wd2 = make_withdraw(7)
第二個withdraw函數綁定到了全局幀的名稱wd2上。我們使用星號來省略了表示這個綁定的線。現在,我們看到實際上有兩個balance的綁定。名稱wd仍舊綁定到餘額為12的withdraw函數上,而wd2綁定到了餘額為7的新的withdraw函數上。

最後,我們調用綁定到wd2上的第二個withdraw函數:
>>> wd2(6)
1
這個調用修改了非局部名稱balance的綁定,但是不影響在全局幀中綁定到名稱wd的第一個withdraw。

這樣,withdraw的每個實例都維護它自己的餘額狀態,但是這個狀態對程序中其它函數不可見。在更高層面上觀察這個情況,我們創建了銀行賬戶的抽象,它管理自己的內部狀態,但以一種方式對真實世界的賬戶進行建模:它基於自己的歷史提取請求來隨時間變化。
2.4.3 非局部賦值的代價
我們擴展了我們的計算環境模型,用於解釋非局部賦值的效果。但是,非局部複製與我們思考名稱和值的方式有一些細微差異。
之前,我們的值並沒有改變,僅僅是我們的名稱和綁定發生了變化。當兩個名稱a和b綁定到4上時,它們綁定到了相同的4還是不同的4並不重要。我們說,只有一個4對象,並且它永不會改變。
但是,帶有狀態的函數不是這樣的。當兩個名稱wd和wd2都綁定到withdraw函數時,它們綁定到相同函數還是函數的兩個不同實例,就很重要了。考慮下面的例子,它與我們之前分析的那個正好相反:
>>> wd = make_withdraw(12)
>>> wd2 = wd
>>> wd2(1)
11
>>> wd(1)
10
這裡,通過wd2調用函數會修改名稱為wd的函數的值,因為兩個名稱都指向相同的函數。這些語句執行之後的環境圖示展示了這個現象:

兩個名稱指向同一個值在世界上不常見,但我們程序中就是這樣。但是,由於值會隨時間改變,我們必須非常仔細來理解其它名稱上的變化效果,它們可能指向這些值。
正確分析帶有非局部賦值代碼的關鍵是,記住只有函數調用可以創建新的幀。賦值語句始終改變現有幀中的綁定。這裡,除非make_withdraw調用了兩次,balance還是隻有一個綁定。
**變與不變。**這些細微差別出現的原因是,通過引入修改非局部環境的非純函數,我們改變了表達式的本質。只含有純函數的表達式是引用透明(referentially transparent)的。如果我們將它的子表達式換成子表達式的值,它的值不會改變。
重新綁定的操作違反了引用透明的條件,因為它們不僅僅返回一個值。它們修改了環境。當我們引入任意重綁定的時候,我們就會遇到一個棘手的認識論問題:它對於兩個相同的值意味著什麼。在我們的計算環境模型中,兩個分別定義的函數並不是相同的,因為其中一個的改變並不影響另一個。
通常,只要我們不會修改數據對象,我們就可以將複合數據對象看做其部分的總和。例如,有理數可以通過提供分子和分母來確定。但是這個觀點在變化出現時不再成立了,其中複合數據對象擁有一個“身份”,不同於組成它的各個部分。即使我們通過取錢來修改了餘額,某個銀行賬戶還是“相同”的銀行賬戶。相反,我們可以讓兩個銀行賬戶碰巧具有相同的餘額,但它們是不同的對象。
儘管它引入了新的困難,非局部賦值是個創建模塊化編程的強大工具,程序的不同部分,對應不同的環境幀,可以在程序執行中獨立演化。而且,使用帶有局部狀態的函數,我們就能實現可變數據類型。在這一節的剩餘部分,我們介紹了一些最實用的 Python 內建數據類型,以及使用帶有非局部賦值的函數,來實現這些數據類型的一些方法。
2.4.4 列表
list是 Python 中最使用和靈活的數據類型。列表類似於元組,但是它是可變的。方法調用和賦值語句都可以修改列表的內容。
我們可以通過一個展示(極大簡化的)撲克牌歷史的例子,來介紹許多列表編輯操作。例子中的註釋描述了每個方法的效果。
撲克牌發明於中國,大概在 9 世紀。早期的牌組中有三個花色,它們對應錢的三個面額。
>>> chinese_suits = ['coin', 'string', 'myriad'] # A list literal
>>> suits = chinese_suits # Two names refer to the same list
撲克牌傳到歐洲(也可能通過埃及)之後,西班牙的牌組(oro)中之只保留了硬幣的花色。
>>> suits.pop() # Removes and returns the final element
'myriad'
>>> suits.remove('string') # Removes the first element that equals the argument
然後又添加了三個新的花色(它們的設計和名稱隨時間而演化),
>>> suits.append('cup') # Add an element to the end
>>> suits.extend(['sword', 'club']) # Add all elements of a list to the end
意大利人把劍叫做“黑桃”:
>>> suits[2] = 'spade' # Replace an element
下面是傳統的意大利牌組:
>>> suits
['coin', 'cup', 'spade', 'club']
我們現在在美國使用的法式變體修改了前兩個:
>>> suits[0:2] = ['heart', 'diamond'] # Replace a slice
>>> suits
['heart', 'diamond', 'spade', 'club']
也存在用於插入、排序和反轉列表的操作。所有這些修改操作都改變了列表的值,它們並不創建新的列表對象。
**共享和身份。**由於我們修改了一個列表,而不是創建新的列表,綁定到名稱chinese_suits上的對象也改變了,因為它與綁定到suits上的對象是相同的列表對象。
>>> chinese_suits # This name co-refers with "suits" to the same list
['heart', 'diamond', 'spade', 'club']
列表可以使用list構造函數來複制。其中一個的改變不會影響另一個,除非它們共享相同的結構。
>>> nest = list(suits) # Bind "nest" to a second list with the same elements
>>> nest[0] = suits # Create a nested list
在最後的賦值之後,我們只剩下下面的環境,其中列表使用盒子和指針的符號來表示:

根據這個環境,修改由suites指向的列表會影響nest第一個元素的嵌套列表,但是不會影響其他元素:
>>> suits.insert(2, 'Joker') # Insert an element at index 2, shifting the rest
>>> nest
[['heart', 'diamond', 'Joker', 'spade', 'club'], 'diamond', 'spade', 'club']
與之類似,在next的第一個元素上撤銷這個修改也會影響到suit。
由於這個pop方法的調用,我們返回到了上面描述的環境。
由於兩個列表具有相同內容,但是實際上是不同的列表,我們需要一種手段來測試兩個對象是否相同。Python 引入了兩個比較運算符,叫做is和is not,測試了兩個表達式實際上是否求值為同一個對象。如果兩個對象的當前值相等,並且一個對象的改變始終會影響另一個,那麼兩個對象是同一個對象。身份是個比相等性更強的條件。
譯者注:兩個對象當且僅當在內存中的位置相同時為同一個對象。CPython 的實現直接比較對象的地址來確定。
>>> suits is nest[0]
True
>>> suits is ['heart', 'diamond', 'spade', 'club']
False
>>> suits == ['heart', 'diamond', 'spade', 'club']
True
最後的兩個比較展示了is和==的區別,前者檢查身份,而後者檢查內容的相等性。
**列表推導式。**列表推導式使用擴展語法來創建列表,與生成器表達式的語法相似。
例如,unicodedata模塊跟蹤了 Unicode 字母表中每個字符的官方名稱。我們可以查找與名稱對應的字符,包含這些卡牌花色的字符。
>>> from unicodedata import lookup
>>> [lookup('WHITE ' + s.upper() + ' SUIT') for s in suits]
['♡', '♢', '♤', '♧']
列表推導式使用序列的接口約定增強了數據處理的範式,因為列表是一種序列數據類型。
**擴展閱讀。**Dive Into Python 3 的推導式一章包含了一些示例,展示瞭如何使用 Python 瀏覽計算機的文件系統。這一章介紹了os模塊,它可以列出目錄的內容。這個材料並不是這門課的一部分,但是推薦給任何想要增加 Python 知識和技巧的人。
**實現。**列表是序列,就像元組一樣。Python 語言並不提供給我們列表實現的直接方法,只提供序列抽象,和我們在這一節介紹的可變方法。為了克服這一語言層面的抽象界限,我們可以開發列表的函數式實現,再次使用遞歸表示。這一節也有第二個目的:加深我們對調度函數的理解。
我們會將列表實現為函數,它將一個遞歸列表作為自己的局部狀態。列表需要有一個身份,就像任何可變值那樣。特別地,我們不能使用None來表示任何空的可變列表,因為兩個空列表並不是相同的值(例如,向一個列表添加元素並不會添加到另一個),但是None is None。另一方面,兩個不同的函數足以區分兩個兩個空列表,它們都將empty_rlist作為局部狀態。
我們的可變列表是個調度函數,就像我們偶對的函數式實現也是個調度函數。它檢查輸入“信息”是否為已知信息,並且對每個不同的輸入執行相應的操作。我們的可變列表可響應五個不同的信息。前兩個實現了序列抽象的行為。接下來的兩個添加或刪除列表的第一個元素。最後的信息返回整個列表內容的字符串表示。
>>> def make_mutable_rlist():
"""Return a functional implementation of a mutable recursive list."""
contents = empty_rlist
def dispatch(message, value=None):
nonlocal contents
if message == 'len':
return len_rlist(contents)
elif message == 'getitem':
return getitem_rlist(contents, value)
elif message == 'push_first':
contents = make_rlist(value, contents)
elif message == 'pop_first':
f = first(contents)
contents = rest(contents)
return f
elif message == 'str':
return str(contents)
return dispatch
我們也可以添加一個輔助函數,來從任何內建序列中構建函數式實現的遞歸列表。只需要以遞歸順序添加每個元素。
>>> def to_mutable_rlist(source):
"""Return a functional list with the same contents as source."""
s = make_mutable_rlist()
for element in reversed(source):
s('push_first', element)
return s
在上面的定義中,函數reversed接受並返回可迭代值。它是使用序列的接口約定的另一個示例。
這裡,我們可以構造函數式實現的列表,要注意列表自身也是個函數。
>>> s = to_mutable_rlist(suits)
>>> type(s)
<class 'function'>
>>> s('str')
"('heart', ('diamond', ('spade', ('club', None))))"
另外,我們可以像列表s傳遞信息來修改它的內容,比如移除第一個元素。
>>> s('pop_first')
'heart'
>>> s('str')
"('diamond', ('spade', ('club', None)))"
原則上,操作push_first和pop_first足以對列表做任意修改。我們總是可以清空整個列表,之後將它舊的內容替換為想要的結果。
**消息傳遞。**給予一些時間,我們就能實現許多實用的 Python 列表可變操作,比如extend和insert。我們有一個選擇:我們可以將它們全部實現為函數,這會使用現有的消息pop_first和push_first來實現所有的改變操作。作為代替,我們也可以向dispatch函數體添加額外的elif子句,每個子句檢查一個消息(例如'extend'),並且直接在contents上做出合適的改變。
第二個途徑叫做消息傳遞,它把數據值上面所有操作的邏輯封裝在一個函數中,這個函數響應不同的消息。一個使用消息傳遞的程序定義了調度函數,每個函數都擁有局部狀態,通過傳遞“消息”作為第一個參數給這些函數來組織計算。消息是對應特定行為的字符串。
可以想象,在dispatch的函數體中通過名稱來枚舉所有這些消息非常無聊,並且易於出現錯誤。Python 的字典提供了一種數據類型,會幫助我們管理消息和操作之間的映射,它會在下一節中介紹。
2.4.5 字典
字典是 Python 內建數據類型,用於儲存和操作對應關係。字典包含了鍵值對,其中鍵和值都可以是對象。字典的目的是提供一種抽象,用於儲存和獲取下標不是連續整數,而是描述性的鍵的值。
字符串通常用作鍵,因為字符串通常用於表示事物名稱。這個字典字面值提供了不同羅馬數字的值。
>>> numerals = {'I': 1.0, 'V': 5, 'X': 10}
我們可以使用元素選擇運算符,來通過鍵查找值,我們之前將其用於序列。
>>> numerals['X']
10
字典的每個鍵最多隻能擁有一個值。添加新的鍵值對或者修改某個鍵的已有值,可以使用賦值運算符來完成。
>>> numerals['I'] = 1
>>> numerals['L'] = 50
>>> numerals
{'I': 1, 'X': 10, 'L': 50, 'V': 5}
要注意,'L'並沒有添加到上面輸出的末尾。字典是無序的鍵值對集合。當我們打印字典時,鍵和值都以某種順序來渲染,但是對語言的用戶來說,不應假設順序總是這樣。
字典抽象也支持多種方法,來從整體上迭代字典中的內容。方法keys、values和items都返回可迭代的值。
>>> sum(numerals.values())
66
通過調用dict構造函數,鍵值對的列表可以轉換為字典。
>>> dict([(3, 9), (4, 16), (5, 25)])
{3: 9, 4: 16, 5: 25}
字典也有一些限制:
- 字典的鍵不能是可變內建類型的對象。
- 一個給定的鍵最多隻能有一個值。
第一條限制被綁定到了 Python 中字典的底層實現上。這個實現的細節並不是這門課的主題。直覺上,鍵告訴了 Python 應該在內存中的哪裡尋找鍵值對;如果鍵發生改變,鍵值對就會丟失。
第二個限制是字典抽象的結果,它為儲存和獲取某個鍵的值而設計。如果字典中最多隻存在一個這樣的值,我們只能獲取到某個鍵的一個值。
由字典實現的一個實用方法是get,如果鍵存在的話,它返回鍵的值,否則返回一個默認值。get的參數是鍵和默認值。
>>> numerals.get('A', 0)
0
>>> numerals.get('V', 0)
5
字典也擁有推導式語法,和列表和生成器表達式類似。求解字典推導式會產生新的字典對象。
>>> {x: x*x for x in range(3,6)}
{3: 9, 4: 16, 5: 25}
**實現。**我們可以實現一個抽象數據類型,它是一個記錄的列表,與字典抽象一致。每個記錄都是兩個元素的列表,包含鍵和相關的值。
>>> def make_dict():
"""Return a functional implementation of a dictionary."""
records = []
def getitem(key):
for k, v in records:
if k == key:
return v
def setitem(key, value):
for item in records:
if item[0] == key:
item[1] = value
return
records.append([key, value])
def dispatch(message, key=None, value=None):
if message == 'getitem':
return getitem(key)
elif message == 'setitem':
setitem(key, value)
elif message == 'keys':
return tuple(k for k, _ in records)
elif message == 'values':
return tuple(v for _, v in records)
return dispatch
同樣,我們使用了傳遞方法的消息來組織我們的實現。我們已經支持了四種消息:getitem、setitem、keys和values。要查找某個鍵的值,我們可以迭代這些記錄來尋找一個匹配的鍵。要插入某個鍵的值,我們可以迭代整個記錄來觀察是否已經存在帶有這個鍵的記錄。如果沒有,我們會構造一條新的記錄。如果已經有了帶有這個鍵的記錄,我們將這個記錄的值設為新的值。
我們現在可以使用我們的實現來儲存和獲取值。
>>> d = make_dict()
>>> d('setitem', 3, 9)
>>> d('setitem', 4, 16)
>>> d('getitem', 3)
9
>>> d('getitem', 4)
16
>>> d('keys')
(3, 4)
>>> d('values')
(9, 16)
這個字典實現並不為快速的記錄檢索而優化,因為每個響應getitem消息都必須迭代整個records列表。內建的字典類型更加高效。
2.4.6 示例:傳播約束
可變數據允許我們模擬帶有變化的系統,也允許我們構建新的抽象類型。在這個延伸的實例中,我們組合了非局部賦值、列表和字典來構建一個基於約束的系統,支持多個方向上的計算。將程序表達為約束是一種聲明式編程,其中程序員聲明需要求解的問題結構,但是抽象了問題解決方案如何計算的細節。
計算機程序通常組織為單方向的計算,它在預先設定的參數上執行操作,來產生合理的輸出。另一方面,我們通常希望根據數量上的關係對系統建模。例如,我們之前考慮過理想氣體定律,它通過波爾茲曼常數k關聯了理想氣體的氣壓p,體積v,數量n以及溫度t。
p * v = n * k * t
這樣一個方程並不是單方向的。給定任何四個數量,我們可以使用這個方程來計算第五個。但將這個方程翻譯為某種傳統的計算機語言會強迫我們選擇一個數量,根據其餘四個計算出來。所以計算氣壓的函數應該不能用於計算溫度,即使二者的計算通過相同的方程完成。
這一節中,我們從零開始設計線性計算的通用模型。我們定義了數量之間的基本約束,例如adder(a, b, c)會嚴格保證數學關係a + b = c。
我們也定義了組合的手段,使基本約束可以被組合來表達更復雜的關係。這樣,我們的程序就像一種編程語言。我們通過構造網絡來組合約束,其中約束由連接器連接。連接器是一種對象,它“持有”一個值,並且可能會參與一個或多個約束。
例如,我們知道華氏和攝氏溫度的關係是:
9 * c = 5 * (f - 32)
這個等式是c和f之間的複雜約束。這種約束可以看做包含adder、multiplier和contant約束的網絡。

這張圖中,我們可以看到,左邊是一個帶有三個終端的乘法器盒子,標記為a,b和c。它們將乘法器連接到網絡剩餘的部分:終端a鏈接到了連接器celsius上,它持有攝氏溫度。終端b鏈接到了連接器w上,w也鏈接到持有9的盒子上。終端c,被乘法器盒子約束為a和b的乘積,鏈接到另一個乘法器盒子上,它的b鏈接到常數5上,以及它的a連接到了求和約束的一項上。
這個網絡上的計算會如下進行:當連接器被提供一個值時(被用戶或被鏈接到它的約束器),它會喚醒所有相關的約束(除了剛剛喚醒的約束)來通知它們它得到了一個值。每個喚醒的約束之後會調查它的連接器,來看看是否有足夠的信息來為連接器求出一個值。如果可以,盒子會設置這個連接器,連接器之後會喚醒所有相關的約束,以此類推。例如,在攝氏溫度和華氏溫度的轉換中,w、x和y會被常量盒子9、5和32立即設置。連接器會喚醒乘法器和加法器,它們判斷出沒有足夠的信息用於處理。如果用戶(或者網絡的其它部分)將celsis連接器設置為某個值(比如25),最左邊的乘法器會被喚醒,之後它會將u設置為25 * 9 = 225。之後u會喚醒第二個乘法器,它會將v設置為45,之後v會喚醒加法器,它將fahrenheit連接器設置為77。
**使用約束系統。**為了使用約束系統來計算出上面所描述的溫度計算,我們首先創建了兩個具名連接器,celsius和fahrenheit,通過調用make_connector構造器。
>>> celsius = make_connector('Celsius')
>>> fahrenheit = make_connector('Fahrenheit')
之後,我們將這些連接器鏈接到網絡中,這個網絡反映了上面的圖示。函數make_converter組裝了網絡中不同的連接器和約束:
>>> def make_converter(c, f):
"""Connect c to f with constraints to convert from Celsius to Fahrenheit."""
u, v, w, x, y = [make_connector() for _ in range(5)]
multiplier(c, w, u)
multiplier(v, x, u)
adder(v, y, f)
constant(w, 9)
constant(x, 5)
constant(y, 32)
>>> make_converter(celsius, fahrenheit)
我們會使用消息傳遞系統來協調約束和連接器。我們不會使用函數來響應消息,而是使用字典。用於分發的字典擁有字符串類型的鍵,代表它接受的消息。這些鍵關聯的值是這些消息的響應。
約束是不帶有局部狀態的字典。它們對消息的響應是非純函數,這些函數會改變所約束的連接器。
連接器是一個字典,持有當前值並響應操作該值的消息。約束不會直接改變連接器的值,而是會通過發送消息來改變,於是連接器可以提醒其他約束來響應變化。這樣,連接器代表了一個數值,同時封裝了連接器的行為。
我們可以發送給連接器的一種消息是設置它的值。這裡,我們('user')將celsius的值設置為25。
>>> celsius['set_val']('user', 25)
Celsius = 25
Fahrenheit = 77.0
不僅僅是celsius的值變成了25,它的值也在網絡上傳播,於是fahrenheit的值也發生變化。這些變化打印了出來,因為我們在構造這兩個連接器的時候命名了它們。
現在我們可以試著將fahrenheit設置為新的值,比如212。
>>> fahrenheit['set_val']('user', 212)
Contradiction detected: 77.0 vs 212
連接器報告說,它察覺到了一個矛盾:它的值是77.0,但是有人嘗試將其設置為212。如果我們真的想以新的值複用這個網絡,我們可以讓celsius忘掉舊的值。
>>> celsius['forget']('user')
Celsius is forgotten
Fahrenheit is forgotten
連接器celsius發現了user,一開始設置了它的值,現在又想撤銷這個值,所以celsius同意丟掉這個值,並且通知了網絡的其餘部分。這個消息最終傳播給fahrenheit,它現在發現沒有理由繼續相信自己的值為77。於是,它也丟掉了它的值。
現在fahrenheit沒有值了,我們就可以將其設置為212:
>>> fahrenheit['set_val']('user', 212)
Fahrenheit = 212
Celsius = 100.0
這個新值在網絡上傳播,並強迫celsius持有值100。我們已經使用了非常相似的網絡,提供fahrenheit來計算celsius,以及提供celsius來計算fahrenheit。這個無方向的計算就是基於約束的網絡的特徵。
**實現約束系統。**像我們看到的那樣,連接器是字典,將消息名稱映射為函數和數據值。我們將要實現響應下列消息的連接器:
connector['set_val'](source, value)表示source請求連接器將當前值設置為該值。connector['has_val']()返回連接器是否已經有了一個值。connector['val']是連接器的當前值。connector['forget'](source)告訴連接器,source請求它忘掉當前值。connector['connect'](source)告訴連接器參與新的約束source。
約束也是字典,接受來自連接器的以下兩種消息:
constraint['new_val']()表示連接到約束的連接器有了新的值。constraint['forget']()表示連接到約束的連接器需要忘掉它的值。
當約束收到這些消息時,它們適當地將它們傳播給其它連接器。
adder函數在兩個連接器上構造了加法器約束,其中前兩個連接器必須加到第三個上:a + b = c。為了支持多方向的約束傳播,加法器必須也規定從c中減去a會得到b,或者從c中減去b會得到a。
>>> from operator import add, sub
>>> def adder(a, b, c):
"""The constraint that a + b = c."""
return make_ternary_constraint(a, b, c, add, sub, sub)
我們希望實現一個通用的三元(三個方向)約束,它使用三個連接器和三個函數來創建約束,接受new_val和forget消息。消息的響應是局部函數,它放在叫做constraint的字典中。
>>> def make_ternary_constraint(a, b, c, ab, ca, cb):
"""The constraint that ab(a,b)=c and ca(c,a)=b and cb(c,b) = a."""
def new_value():
av, bv, cv = [connector['has_val']() for connector in (a, b, c)]
if av and bv:
c['set_val'](constraint, ab(a['val'], b['val']))
elif av and cv:
b['set_val'](constraint, ca(c['val'], a['val']))
elif bv and cv:
a['set_val'](constraint, cb(c['val'], b['val']))
def forget_value():
for connector in (a, b, c):
connector['forget'](constraint)
constraint = {'new_val': new_value, 'forget': forget_value}
for connector in (a, b, c):
connector['connect'](constraint)
return constraint
叫做constraint的字典是個分發字典,也是約束對象自身。它響應兩種約束接收到的消息,也在對連接器的調用中作為source參數傳遞。
無論約束什麼時候被通知,它的連接器之一擁有了值,約束的局部函數new_value都會被調用。這個函數首先檢查是否a和b都擁有值,如果是這樣,它告訴c將值設為函數ab的返回值,在adder中是add。約束,也就是adder對象,將自身作為source參數傳遞給連接器。如果a和b不同時擁有值,約束會檢查a和c,以此類推。
如果約束被通知,連接器之一忘掉了它的值,它會請求所有連接器忘掉它們的值(只有由約束設置的值會被真正丟掉)。
multiplier與adder類似:
>>> from operator import mul, truediv
>>> def multiplier(a, b, c):
"""The constraint that a * b = c."""
return make_ternary_constraint(a, b, c, mul, truediv, truediv)
常量也是約束,但是它不會發送任何消息,因為它只包含一個單一的連接器,在構造的時候會設置它。
>>> def constant(connector, value):
"""The constraint that connector = value."""
constraint = {}
connector['set_val'](constraint, value)
return constraint
這三個約束足以實現我們的溫度轉換網絡。
**表示連接器。**連接器表示為包含一個值的字典,但是同時擁有帶有局部狀態的響應函數。連接器必須跟蹤向它提供當前值的informant,以及它所參與的constraints列表。
構造器make_connector是局部函數,用於設置和忘掉值,它響應來自約束的消息。
>>> def make_connector(name=None):
"""A connector between constraints."""
informant = None
constraints = []
def set_value(source, value):
nonlocal informant
val = connector['val']
if val is None:
informant, connector['val'] = source, value
if name is not None:
print(name, '=', value)
inform_all_except(source, 'new_val', constraints)
else:
if val != value:
print('Contradiction detected:', val, 'vs', value)
def forget_value(source):
nonlocal informant
if informant == source:
informant, connector['val'] = None, None
if name is not None:
print(name, 'is forgotten')
inform_all_except(source, 'forget', constraints)
connector = {'val': None,
'set_val': set_value,
'forget': forget_value,
'has_val': lambda: connector['val'] is not None,
'connect': lambda source: constraints.append(source)}
return connector
同時,連接器是一個分發字典,用於分發五個消息,約束使用它們來和連接器通信。前四個響應都是函數,最後一個響應就是值本身。
局部函數set_value在請求設置連接器的值時被調用。如果連接器當前並沒有值,它會設置該值並將informant記為請求設置該值的source約束。之後連接器會提醒所有參與的約束,除了請求設置該值的約束。這通過使用下列迭代函數來完成。
>>> def inform_all_except(source, message, constraints):
"""Inform all constraints of the message, except source."""
for c in constraints:
if c != source:
c[message]()
如果一個連接器被請求忘掉它的值,它會調用局部函數forget_value,這個函數首先執行檢查,來確保請求來自之前設置該值的同一個約束。如果是的話,連接器通知相關的約束來丟掉當前值。
對has_val消息的響應表示連接器是否擁有一個值。對connect消息的響應將source約束添加到約束列表中。
我們設計的約束程序引入了許多出現在面向對象編程的概念。約束和連接器都是抽象,它們通過消息來操作。當連接器的值由消息改變時,消息不僅僅改變了它的值,還對其驗證(檢查來源)並傳播它的影響。實際上,在這一章的後面,我們會使用相似的字符串值的字典結構和函數值來實現面向對象系統。