迅速提高你的Python:理解Python的執行模型
作者:Jeff Knupp
原文地址:https://jeffknupp.com/blog/2013/02/14/drastically-improve-your-python-understanding-pythons-execution-model/
Python新手通常對他們自己的程式碼感到驚訝。他們期望A,但看起來沒有原因,發生了B。許多這些“驚奇”的根本原因是混淆了Python執行模型。它這樣的,如果向你解釋一次,一些Python概念在變得清晰之前,看起來是模糊不清的。僅僅靠你自己去“弄清楚”也是很困難的,因為它要求對核心語言概念,如變數、物件及函式的思考有根本性的轉變。
在本文,我將幫助你理解,在建立變數或呼叫函式等常見操作背後發生看什麼。因此,你將編寫更清晰、更易於理解的程式碼。你還成為一個更好(更快)的程式碼讀者。所需要的就是忘記你所知道的關於程式設計的一切……
一切都是一個物件?
在大多數人第一次聽到在Python裡“一切都是一個物件”時,這觸發了對Java等語言的記憶重現,其中使用者編寫的每一件東西都封裝在一個物件裡。其他人假設這意味著在Python直譯器的實現中,一切都實現為物件。第一個解釋的錯誤的;第二個成立,但不是特別有趣(對我們的目的)。這個短語實際指的是所有“事物”這個事實,不管它們是值、類、函式、物件例項(顯然)以及幾乎其他語言構造,概念上是一個物件。
一切都是物件意味著什麼?它意味著提到的“事物”有我們通常與物件聯絡起來的所有屬性(以面向物件的觀念);型別有成員函式,函式有屬性(attribute),模組可以作為實參傳遞等。它對
Python直譯器通常搞混初學者的一個特性是,在一個賦值給一個使用者定義物件的“變數”上呼叫print()時會發生什麼(稍後我會解釋引號)。使用內建型別,通常打印出一個正確的值,像在string及int上呼叫print()時。但於簡單的使用者定義類,直譯器吐出一些難看的字串,像:
>>> class Foo(): pass
>>> foo = Foo()
>>> print(foo)
<__main__.Foo object at 0xd3adb33f>
Print()
回答是,我們需要理解foo實際上在Python裡代表什麼。大多數其他語言稱它為變數。實際上,許多Python文章把foo稱為一個變數,但實際上僅作為一個速記法。
在像C的語言裡,foo代表“東西”所用的儲存。如果我們寫
int foo = 42;
說整形變數foo包含值42是正確的。即,變數是值的一種容器。
現在來看一些完全不同的東西
在Python中,不是這樣的。在我們這樣宣告時:
>>> foo = Foo()
說foo“包含”一個Foo物件是錯誤的。相反,foo是繫結到由Foo()建立的物件的名字。等式右手側部分建立了一個物件。把foo賦值為這個物件只是說“我希望能夠把這個物件稱作foo”。替代(在傳統意義上的)變數,Python有名字(name)與繫結(binding)。
因此,在之前我們列印foo時,直譯器展示給我們的是記憶體中foo繫結的物件儲存的地址。這不像它聽起來那麼無用。如果你在直譯器中,並希望檢視兩個名字是否繫結到相同的物件,通過列印它們、比較地址,你可以進行一次權宜的檢查。如果它們匹配,它們繫結到同一個物件;如果不匹配,它們繫結到不同的物件。當然,檢查兩個名字是否繫結到同一個物件慣用的方法是使用is
如果我們繼續我們的例子並寫出
>>> baz = foo
我們應該把這讀作“將名字baz繫結到foo所繫結的相同物件(不管是什麼)。”這應該是清楚的,那麼為什麼會發生下面的情況
>>> baz.some_attribute
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'Foo' object has no attribute 'some_attribute'
>>> foo.some_attribute = 'set from foo'
>>> baz.some_attribute
'set from foo'
使用foo以某種發生改變物件也將反映到baz裡:它們都繫結到底下相同的物件。
名字裡有什麼……
Python裡的名字並非不像真實世界中的名字。如果我妻子叫我“Jeff”,我爸爸叫我“Jeffrey”,而我老闆叫我“程式設計隊長”,很好,但它沒有改變我任何一點。不過,如果我妻子殺死了“Jeff”(以及埋怨她的人),意味著“程式設計隊長”也被殺死了。類似的,在Python中將一個名字繫結到一個物件不改變它。不過,改變該物件的某個屬性,將反映在繫結到該物件的所有其他名字裡。
一切確實是物件。我發誓
這裡,提出了一個問題:我們怎麼知道等號右手側的東西總是一個我們可以繫結一個名字的物件?下面怎麼樣
>>> foo = 10
或者
>>> foo = "Hello World!"
現在是“一切都是物件”回報的時候了。在Python裡,任何你可以放在等號右手側的東西是(或建立了)一個物件。10與Hello World都是物件。不相信我?你自己看
>>> foo = 10
>>> print(foo.__add__)
<method-wrapper '__add__' of int object at 0x8502c0>
如果10實際上只是數字10,它不可能有一個__add__屬性(或者其他任何屬性)。
實際上,使用dir()函式,我們可以看到10的所有屬性:
>>> dir(10)
['__abs__', '__add__', '__and__', '__class__', '__cmp__', '__coerce__', '__delattr__',
'__div__', '__divmod__', '__doc__', '__float__', '__floordiv__', '__format__',
'__getattribute__', '__getnewargs__', '__hash__', '__hex__', '__index__',
'__init__', '__int__', '__invert__', '__long__', '__lshift__', '__mod__',
'__mul__', '__neg__', '__new__', '__nonzero__', '__oct__', '__or__',
'__pos__', '__pow__', '__radd__', '__rand__', '__rdiv__', '__rdivmod__',
'__reduce__', '__reduce_ex__', '__repr__', '__rfloordiv__', '__rlshift__',
'__rmod__', '__rmul__', '__ror__', '__rpow__', '__rrshift__', '__rshift__',
'__rsub__', '__rtruediv__', '__rxor__', '__setattr__', '__sizeof__', '__str__',
'__sub__', '__subclasshook__', '__truediv__', '__trunc__', '__xor__',
'bit_length', 'conjugate', 'denominator', 'imag', 'numerator', 'real']
帶有所有這些屬性與成員函式,我覺得說10是一個物件是安全的。
因為Python裡一切本質上是繫結到物件的名字,我們可以做像這樣(有趣)的蠢事:
>>> import datetime
>>> import imp
>>> datetime.datetime.now()
datetime.datetime(2013, 02, 14, 02, 53, 59, 608842)
>>> class PartyTime():
... def __call__(self, *args):
... imp.reload(datetime)
... value = datetime.datetime(*args)
... datetime.datetime = self
... return value
...
... def __getattr__(self, value):
... if value == 'now':
... return lambda: print('Party Time!')
... else:
... imp.reload(datetime)
... value = getattr(datetime.datetime, value)
... datetime.datetime = self
... return value
>>> datetime.datetime = PartyTime()
>>> datetime.datetime.now()
Party Time!
>>> today = datetime.datetime(2013, 2, 14)
>>> print(today)
2013-02-14 00:00:00
>>> print(today.timestamp())
1360818000.0
Datetime.datetime只是一個名字(恰好繫結到表示datetime類的一個物件)。我們可以隨心重新繫結它。在上面的例子中,我們將datetime的datetime屬性繫結到我們的新類,PartyTime。任何對datetime.datetime建構函式的呼叫返回一個有效的datetime物件。實際上,這個類與真實的datetime.datetime類沒有區別。即,除了如果你呼叫datetime.datetime.now()它總是列印’Party Time!’這個事實。
顯然,這是一個愚蠢的例子,但希望它能給予你某些洞察,在你完全理解並使用Python的執行模型時,什麼是可能的。不過,現在我們僅改變了與一個名字關聯的繫結。改變物件本身會怎麼樣?
物件的兩個型別
事實證明Python有兩種物件:可變(mutable)與不可變(Immutable)。可變物件的值在建立後可以改變。不可變物件的值不能。List是可變物件。你可以建立一個列表,新增一些值,這個列表就地更新。String是不可變的。一旦你建立一個字串,你不能改變它的值。
我知道你的想法:“當然,你可以改變一個字串的值,我在程式碼裡總是這樣做!”在你“改變”一個字串時,你實際上把它重新繫結到一個新建立的字串物件。原來的物件維持不變,即使可能沒人再引用它了。
你自己看:
>>> a = 'foo'
>>> a
'foo'
>>> b = a
>>> a += 'bar'
>>> a
'foobar'
>>> b
'foo'
即使我們使用+=,並且看起來我們修改了這個字串,我們實際上只是得到了包含改變結果的新字串。這是為什麼你可能聽到別人說,“字串串接是慢的。”這是因為串接字串必須為新字串分配記憶體並拷貝內容,而附加到一個list(在大多數情形裡)不要求記憶體分配。不可變物件“改變”本質上代價高昂,因為這樣做設計建立一個拷貝。改變可變物件是廉價的。
不可變物件的離奇性
在我說不可變物件的值在建立後不能改變時,這不是全部的事實。Python裡的如果容器,比如tuple,是不可變的。一個tuple的值在它建立後不能改變。但tuple的“值”概念上只是一系列到物件繫結不可變的名字。要注意的關鍵是繫結是不可變的,不是它們繫結的物件。
這意味著下面是完全合法的:
>>> class Foo():
... def __init__(self):
... self.value = 0
... def __str__(self):
... return str(self.value)
... def __repr__(self):
... return str(self.value)
...
>>> f = Foo()
>>> print(f)
0
>>> foo_tuple = (f, f)
>>> print(foo_tuple)
(0, 0)
>>> foo_tuple[0] = 100
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment
>>> f.value = 999
>>> print(f)
999
>>> print(foo_tuple)
(999, 999)
當我們嘗試直接改變這個元組的一個元素時,我們得到一個TypeError,告訴我們(一旦建立),tuple就不可賦值。但改變底下的物件具有“改變”該tuple值的效果。這是一個難以理解的要點,但無疑是重要的:一個不可變物件的“值”不能改變,但它的組成物件可以。
函式呼叫
如果變數只是繫結到物件的名字,當我們把它們作為實參傳遞給一個函式時會發生什麼?事實是,我們實際上沒有傳遞那麼多。看一下這個程式碼:
def add_to_tree(root, value_string):
"""Given a string of characters `value_string`, create or update a
series of dictionaries where the value at each level is a dictionary of
the characters that have been seen following the current character.
Example:
>>> my_string = 'abc'
>>> tree = {}
>>> add_to_tree(tree, my_string)
>>> print(tree['a']['b'])
{'c': {}}
>>> add_to_tree(tree, 'abd')
>>> print(tree['a']['b'])
{'c': {}, 'd': {}}
>>> print(tree['a']['d'])
KeyError 'd'
"""
for character in value_string:
root = root.setdefault(character, {})
我們實際上建立了一個像trie(譯註:基數樹)一樣工作的自復活(auto-vivifying)字典。注意在for迴圈裡我們改變了root引數。在這個函式呼叫完成後,tree仍然是同一個字典,帶有某些更新。它不是這個函式呼叫裡root最後的值。因此,在某種意義上,tree正在更新;在另一種意義上,它沒有。
為了理解這,考慮root引數實際上是什麼:對作為root引數傳遞的名字所援引物件的一個新繫結。在我們的例子中,root是一開始繫結到與tree繫結相同的物件。它不是tree本身,這解釋了為什麼在函式裡將root改變為新字典,tree保持不變。你會記得,把root賦值為root.setdefault(character, {})只是將root重新繫結到由root.setdefault(character, {})語句建立的物件。
下面是另一個更直接明瞭的例子:
def list_changer(input_list):
input_list[0] = 10
input_list = range(1, 10)
print(input_list)
input_list[0] = 10
print(input_list)
>>> test_list = [5, 5, 5]
>>> list_changer(test_list)
[1, 2, 3, 4, 5, 6, 7, 8, 9]
[10, 2, 3, 4, 5, 6, 7, 8, 9]
>>> print test_list
[10, 5, 5]
我們第一條語句確實改變了底下列表的值(我們可以看到最後一行的輸出)。不過,一旦我們通過input_list = range(, 10)重新繫結input_list,我們現在引用一個完全不同的物件。我們實際上說,“將名字input_list繫結到這個新list”。在這行之後,我們沒有辦法再次引用原來的input_list引數了。
到現在為止,你應該清楚理解繫結一個名字如何工作了。還有一件事情要小心。
塊與作用域
現在,名字、繫結及物件的概念應該相當熟悉了。不過,我們尚未觸及的是直譯器如何“找到”一個名字。為了理解我的意思,考慮下面的程式碼:
GLOBAL_CONSTANT = 42
def print_some_weird_calculation(value):
number_of_digits = len(str(value))
def print_formatted_calculation(result):
print('{value} * {constant} = {result}'.format(value=value,
constant=GLOBAL_CONSTANT, result=result))
print('{} {}'.format('^' * number_of_digits, '++'))
print('\nKey: ^ points to your number, + points to constant')
print_formatted_calculation(value * GLOBAL_CONSTANT)
>>> print_some_weird_calculation(123)
123 * 42 = 5166
^^^ ++
Key: ^ points to your number, + points to constant
這是一個做作的例子,但有幾件事情應該會引起你的注意。首先,函式print_formatted_calculation如何有value與number_of_digits的訪問權,儘管它們從來沒有作為實參傳遞?其次,這兩個函式如何看起來有對GLOBAL_CONSTANT的訪問權?
答案都是與作用域(scope)相關。在Python中,當一個名字繫結到一個物件時,這個名字僅在其作用域內可用。一個名字的作用域由建立它的塊(block)確定。塊就是作為單個單元執行的一個Python程式碼“塊”。三個最常見的塊型別是模組、類定義,以及函式體。因此,一個名字的作用域就是定義它的最裡層塊。
現在讓我們回到最初的問題:直譯器如何“找到”名字繫結到哪裡(甚或它是否是一個有效名字)?它從檢查最裡層塊的作用域開始。然後,它檢查包含最裡層塊的作用域,然後包含這個作用域的作用域,以此類推。
在函式print_formatted_calculation中,我們引用value。這首先通過檢查最裡層塊的作用域,在這個情形裡是函式體本身。當它沒有找到在那裡定義的value,它檢查定義了print_formatted_calculation的作用域。在我們的情形裡是print_some_weird_calculation函式體。在這裡它找到了名字value,因此它使用這個繫結並停止查詢。對GLOBAL_CONSTANT是一樣的,它只是需要在更高一層查詢:模組(或指令碼)層。定義在這層的一切都被視為一個global名字。這些可以在任何地方訪問。
一些需要注意的事情。名字的作用域擴充套件到任何包含在定義該名字的塊內的塊,除非這個名字重新繫結到這些塊裡的其中一個。如果print_formatted_calculation有行value = 3,那麼在print_some_weird_calculation中名字value的作用域將僅是這個函式。它的作用域將不包括print_formatted_calculation,因為這個塊重新綁定了這個名字。
明智地使用這個能力
有兩個關鍵字可用於告訴直譯器重用一個已經存在的繫結。其他時候,每次我們繫結一個名字,它把這個名字繫結到一個新物件,但僅在當前作用域中。在上面的例子裡,如果我們在print_formatted_calculation中重新繫結value,它將對作為print_formatted_calculation圍合作用域的print_some_weird_calcuation裡的value沒有影響。使用下面兩個關鍵字,我們實際上可以影響我們區域性作用域外的繫結。
global my_variable告訴直譯器使用在最頂層(或“global”作用域)中名字my_varialbe的繫結。在程式碼塊裡放入global my_variable是宣告,“拷貝這個全域性變數的繫結,或者如果你找不到它,在全域性作用域建立這個名字my_variable”的一種方式。類似的,nonlocal my_variable語句指示直譯器使用在最接近的圍合作用域裡定義的名字my_variable的繫結。這是一種重新繫結一個沒有定義在區域性或全域性作用域名字的方式。沒有nonlocal,我們只能在本地作用域或全域性作用域中修改繫結。不過,不像global my_variable,如果我們使用nonlocal my_varialbe,my_variable必須已經存在;如果找不到,它不會被建立。
為了瞭解實際情況,讓我們編寫一個快速示例:
GLOBAL_CONSTANT = 42
print(GLOBAL_CONSTANT)
def outer_scope_function():
some_value = hex(0x0)
print(some_value)
def inner_scope_function():
nonlocal some_value
some_value = hex(0xDEADBEEF)
inner_scope_function()
print(some_value)
global GLOBAL_CONSTANT
GLOBAL_CONSTANT = 31337
outer_scope_function()
print(GLOBAL_CONSTANT)
# Output:
# 42
# 0x0
# 0xdeadbeef
# 31337
通過使用global以及nonlocal,我們能夠使用及改變一個名字現有的繫結,而不是僅僅給這個名字賦值一個新繫結,並丟失舊的繫結。
總結
如果你看完了這篇的文章,祝賀你!希望Python的執行模型更加清晰了。在一篇(短得多)的後續文章中,我將通過幾個例子展示如何可以有趣的方式利用一切都是物件這個事實。直到下次……
If you found this post useful, check out Writing Idiomatic Python. It's filled with common Python idioms and code samples showing the right and wrong way to use them.(廣告,不翻了)