移植到 Python 3

發表於2015-12-27

經歷移植jinja2到python3的痛苦之後,我把專案暫時放一放,因為我怕打破python3的相容。我的做法是隻用一個python2的程式碼庫,然後在安裝的時候用2to3工具翻譯成python3。不幸的是哪怕一點點的改動都會打破迭代開發。如果你選對了python的版本,你可以專心做事,幸運的避免了這個問題。

來自MoinMoin專案的Thomas Waldmann通過我的python-modernize跑jinja2,並且統一了程式碼庫,能同時跑python2,6,2,7和3.3。只需小小清理,我們的程式碼就很清晰,還能跑在所有的python版本上,並且看起來和普通的python程式碼並無區別。

受到他的啟發,我一遍又一遍的閱讀程式碼,並開始合併其他程式碼來享受統一的程式碼庫帶給我的快感。

下面我分享一些小竅門,可以達到和我類似的體驗。

放棄python 2.5 3.1和3.2

這是最重要的一點,放棄2.5比較容易,因為現在基本沒人用了,放棄3.1和3.2也沒太大問題,應為目前python3用的人實在是少得可憐。但是你為什麼放棄這幾個版本呢?答案就是2.6和3.3有很多交叉哦語法和特性,程式碼可以相容這兩個版本。

  • 字串相容。2.6和3.3支援相同的字串語法。你可以用 “foo” 表示原生字串(2.x表示byte,3.x表示unicode),u”foo” 表示unicode字串,b”foo” 表示原生字串或位元組陣列。
  • print函式相容,如果你的print語句比較少,那你可以加上”from __future__ import print_function”,然後開始使用print函式,而不是把它繫結到別的變數上,進而避免詭異的麻煩。
  • 相容的異常語法。Python 2.6引入的 “except Exception as e” 語法也是3.x的異常捕捉語法。
  • 類修飾器都有效。這個可以用在修改介面而不在類結構定義中留下痕跡。例如可以修改迭代方法名字,也就是把 next 改成 __next__ 或者把 __str__ 改成 __unicode__ 來相容python 2.x。
  • 內建next呼叫__next__或next。這點很有用,因為他們和直接呼叫方法的速度差不多,所以你不用考慮得太多而去加入執行時檢查和包裝一個函式。
  • Python 2.6 加入了和python 3.3介面一樣的bytearray型別。這點也很有用,因為2.6沒有 3.3的byteobject型別,雖然有一個內建的名字但那僅僅只是str的別名,並且使用習慣也有很大差異。
  • Python 3.3又加入了byte到byte和string到string的編碼與解碼,這已經在3.1和3.2中去掉了,很不幸,他們的介面很複雜了,別名也沒了,但至少更比以前的2.x版本更接近了。

最後一點在流編碼和解碼的時候很有用,這功能在3.0的時候去掉了,直到3.3才恢復。

沒錯,six模組可以讓你走得遠一點,但是不要低估了程式碼工整度的意義。在Python3移植過程中,我幾乎對jinja2失去了興趣,因為程式碼開始虐我。就算能統一程式碼庫,但還是看起來很不舒服,影響視覺(six.b(‘foo’)和six.u(‘foo’)到處飛)還會因為用2to3迭代開發帶來不必要的麻煩。不用去處理這些麻煩,回到編碼的快樂享受中吧。jinja2現在的程式碼非常清晰,你也不用當心python2和3的相容問題,不過還是有一些地方使用了這樣的語句:if PY2:。

接下來假設這些就是你想支援的python版本,試圖支援python2.5,這是一個痛苦的事情,我強烈建議你放棄吧。支援3.2還有一點點可能,如果你能在把函式呼叫時把字串都包裝起來,考慮到審美和效能,我不推薦這麼做。

跳過six

six是個好東西,jinja2開始也在用,不過最後卻不給力了,因為移植到python3的確需要它,但還是有一些特性丟失了。你的確需要six,如果你想同時支援python2.5,但從2.6開始就沒必要使用six了,jinja2搞了一個包含助手的相容模組。包括很少的非python3 程式碼,整個相容模組不足80行。

因為其他庫或者專案依賴庫的原因,使用者希望你能支援不同版本,這是six的確能為你省去很多麻煩。

開始使用Modernize

使用python-modernize移植python是個很好的還頭,他像2to3一樣執行的時候生成程式碼。當然,他還有很多bug,預設選項也不是很合理,可以避免一些煩人的事情,然你走的更遠。但是你也需要檢查一下結果,去掉一些import 語句和不和諧的東西。

修復測試

做其他事之前先跑一下測試,保證測試還能通過。python3.0和3.1的標準庫就有很多問題是詭異的測試習慣改變引起的。

寫一個相容的模組

因此你將打算跳過six,你能夠完全拋離幫助文件麼?答案當然是否定的。你依然需要一個小的相容模組,但是它足夠小,使得你能夠將它僅僅放在你的包中,下面是一個基本的例子,關於一個相容模組看起來是個什麼樣子:

那個模組確切的內容依賴於,對於你有多少實際的改變。在Jinja2中,我在這裡放了一堆的函式。它包括ifilter, imap以及類似itertools的函式,這些函式都內建在3.x中。(我糾纏Python 2.x函式,是為了讓讀者能夠對程式碼更清楚,迭代器行為是內建的而不是缺陷) 。

為2.x版本做測試而不是3.x

總體上來說你現在正在使用的python是2.x版本的還是3.x版本的是需要檢查的。在這種情況下我推薦你檢查當前版本是否是python2而把python3放到另外一個判斷的分支裡。這樣等python4面世的時候你收到的“驚喜”對你的影響會小一點

好的處理:

相比之下差強人意的處理:

字串處理

Python 3的最大變化毫無疑問是對Unicode介面的更改。不幸的是,這些更改在某些地方非常的痛苦,而且在整個標準庫中還得到了不一致地處理。大多數與字串處理相關的時間函式的移植將完全被廢止。字串處理這個主題本身就可以寫成完整的文件,不過這兒有移植Jinja2和Werkzeug所遵循的簡潔小抄:

  • ‘foo’這種形式的字串總指的是本機字串。這種字串可以用在識別符號裡、原始碼裡、檔名裡和其他底層的函式裡。另外,在2.x裡,只要限制這種字串僅僅可使用ASCII字元,那麼就允許作為Unicode字串常量。
    這個屬性對統一編碼基礎是非常有用的,因為Python 3的正常方向時把Unicode引進到以前不支援Unicode的某些介面,不過反過來卻從不是這樣的。由於這種字串常量“升級”為Unicode,而2.x仍然在某種程度上支援Unicode,因此這種字串常量怎麼用都行。
    例如 datetime.strftime函式在Python2裡嚴格不支援Unicode,並且只在3.x裡支援Unicode。不過因為大多數情況下2.x上的返回值只是ASCII編碼,所以像這樣的函式在2.x和3.x上都確實執行良好。

  • 傳遞給strftime的字串是本機字串(在2.x裡是位元組,而在3.0裡是Unicode)。返回值也是本機字串並且僅僅是ASCII編碼字元。 因此在2.x和3.x上一旦對字串進行格式化,那麼結果就一定是Unicode字串。
  • u’foo’這種形式的字串總指的是Unicode字串,2.x的許多庫都已經有非常好的支援Unicode,因此這樣的字串常量對許多人來說都不應該感到奇怪。
  • b’foo’這種形式的字串總指的是隻以位元組形式儲存的字串。由於2.6確實沒有類似Python 3.3所具有的位元組物件,而且Python 3.3缺乏一個真正的位元組字串,因此這種常量的可用性確實受到小小的限制。當與在2.x和3.x上具有同樣介面的位元組陣列物件繫結在一起時候,它立刻變得更可用了。
    由於這種字串是可以更改的,因此對原始位元組的更改是非常有效的,然後你再次通過使用inbytes()封裝最終結果,從而轉換結果為更易讀的字串。

除了這些基本的規則,我還對上面我的相容模組新增了 text_type,unichr 和 string_types 等變數。通過這些有了大的變化:

  • isinstance(x, basestring) 變成 isinstance(x, string_types).
  • isinstance(x, unicode) 變成 isinstance(x, text_type).
  • isinstance(x, str) 為表明捕捉位元組的意圖,現在變成 isinstance(x, bytes) 或者 isinstance(x, (bytes, bytearray)).

我還建立了一個 implements_to_string 裝飾類,來幫助實現帶有 __unicode__ 或 __str__ 的方法的類:

這個想法是,你只要按2.x和3.x的方式實現 __str__,讓它返回Unicode字串(是的,在2.x裡看起來有點奇怪),裝飾類在2.x裡會自動把它重新命名為 __unicode__,然後新增新的 __str__ 來呼叫 __unicode__ 並把其返回值用 UTF-8 編碼再返回。在過去,這種模式在2.x的模組中已經相當普遍。例如 Jinja2 和 Django 中都這樣用。

下面是一個這種用法的例項:

元類語法的更改

由於Python 3更改了定義元類的語法,並且以一種不相容的方式呼叫元類,所以這使移植比未更改時稍稍難了些。Six有一個with_metaclass函式可以解決這個問題,不過它在繼承樹中產生了一個虛擬類。對Jinjia2移植來說,這個解決方案令我非常 的不舒服,我稍稍地對它進行了修改。這樣對外的API是相同的,只是這種方法使用臨時類與元類相連線。 好處是你使用它時不必擔心效能會受影響並且讓你的繼承樹保持得很完美。
這樣的程式碼理解起來有一點難。 基本的理念是利用這種想法:元類可以自定義類的建立並且可由其父類選擇。這個特殊的解決方法是用元類在建立子類的過程中從繼承樹中刪除自己的父類。最終的結果是這個函式建立了帶有虛擬元類的虛擬類。一旦完成建立虛擬子類,就可以使用虛擬元類了,並且這個虛擬元類必須有從原始父類和真正存在的元類建立新類的構造方法。這樣的話,既是虛擬類又是虛擬元類的類從不會出現。
這種解決方法看起來如下:

下面是你如何使用它:

字典

Python 3裡更令人懊惱的更改之一就是對字典迭代協議的更改。Python2裡所有的字典都具有返回列表的keys()、values()和items(),以及返回迭代器的iterkeys(),itervalues()和iteritems()。在Python3裡,上面的任何一個方法都不存在了。相反,這些方法都用返回檢視物件的新方法取代了。

keys()返回鍵檢視,它的行為類似於某種只讀集合,values()返回只讀容器並且可迭代(不是一個迭代器!),而items()返回某種只讀的類集合物件。然而不像普通的集合,它還可以指向易更改的物件,這種情況下,某些方法在執行時就會遇到失敗。

站在積極的一方面來看,由於許多人沒有理解檢視不是迭代器,所以在許多情況下,你只要忽略這些就可以了。

Werkzeug和Dijango實現了大量自定義的字典物件,並且在這兩種情況下,做出的決定僅僅是忽略檢視物件的存在,然後讓keys()及其友元返回迭代器。

由於Python直譯器的限制,這就是目前可做的唯一合理的事情了。不過存在幾個問題:

  • 檢視本身不是迭代器這個事實意味著通常狀況下你沒有充足的理由建立臨時物件。
  • 內建字典檢視的類集合行為在純Python裡由於直譯器的限制不可能得到複製。
  • 3.x檢視的實現和2.x迭代器的實現意味著有大量重複的程式碼。

下面是Jinja2編碼庫常具有的對字典進行迭代的情形:

為了實現類似物件的字典,類修飾符再次成為可行的方法:

在這種情況下,你需要做的一切就是把keys()和友元方法實現為迭代器,然後剩餘的會自動進行:

通用迭代器的更改

由於一般性地更改了迭代器,所以需要一丁點的幫助就可以使這種更改毫無痛苦可言。真正唯一的更改是從next()到__next__的轉換。幸運的是這個更改已經經過透明化處理。 你唯一真正需要更改的事情是從x.next()到next(x)的更改,而且剩餘的事情由語言來完成。

如果你計劃定義迭代器,那麼類修飾符再次成為可行的方法了:

為了實現這樣的類,只要在所有的版本里定義迭代步長方法__next__就可以了:

轉換編解碼器

Python 2編碼協議的優良特性之一就是它不依賴於型別。 如果你願意把csv檔案轉換為numpy陣列的話,那麼你可以註冊一個這樣的編碼器。然而自從編碼器的主要公共介面與字串物件緊密關聯後,這個特性不再為眾人所知。由於在3.x裡轉換的編解碼器變得更為嚴格,所以許多這樣的功能都被刪除了,不過後來由於證明轉換編解碼有用,在3.3裡重新引入了。基本上來說,所有Unicode到位元組的轉換或者相反的轉換的編解碼器在3.3之前都不可用。hex和base64編碼就位列與這些編解碼的之中。

下面是使用這些編碼器的兩個例子:一個是字串上的操作,一個是基於流的操作。前者就是2.x裡眾所周知的str.encode(),不過,如果你想同時支援2.x和3.x,那麼由於更改了字串API,現在看起來就有些不同了:

同樣,你將注意到在3.3裡,編碼器不理解別名,要求你書寫編碼別名為”base64_codec”而不是”base64″。

(我們優先選擇這些編解碼器而不是選擇binascii模組裡的函式,因為通過對這些編碼器增加編碼和解碼,就可以支援所增加的編碼基於流的操作。)

其他注意事項

仍然有幾個地方我尚未有良好的解決方案,或者說處理這些地方常常令人懊惱,不過這樣的地方會越來越少。不幸是的這些地方的某些現在已經是Python 3 API的一部分,並且很難被發現,直到你觸發一個邊緣情形的時候才能發現它。

  • 在Linux上處理檔案系統和檔案IO訪問仍然令人懊惱,因為它不是基於Unicode的。Open()函式和檔案系統的層都有危險的平臺指定的預設選項。例如,如果我從一臺de_AT的機器SSH到一臺en_US機器,那麼Python對檔案系統和檔案操作就喜歡回退到ASCII編碼上。我注意到通常Python3上對文字操作最可靠的同時也在2.x正常工作的方法是僅僅以二進位制模式開啟檔案,然後顯式地進行解碼。另外,你也可以使用2.x上的codec.open或者io.open函式,以及Python 3上內建的帶有編碼引數的Open函式。
  • 標準庫裡的URL不能用Unicode正確地表示,這使得一些URL在3.x裡不能被正確的處理。
    • 由於更改了語法,所以追溯物件產生的異常需要輔助函式。通常來說這非常罕見,而且很容易處理。下面是由於更改了語法所遇到的情形之一,在這種情況下,你將不得不把程式碼移到exec塊裡。

    • 如果你有部分程式碼依賴於不同的語法的話,那麼通常來說前面的exec技巧是非常有用的。不過現在由於exec本身就有不同的語法,所以你不能用它來執行任何名稱空間上的操作。下面給出的程式碼段不會有大問題,因為把compile用做嵌入函式的eval可執行在兩個版本上。另外你可以通過exec本身啟動一個exec函式。

  • 如果你在Python C API上書寫了C模組,那麼自殺吧。從我知道那刻起到仙子仍然沒有工具可處理這個問題,而且許多東西都已經更改了。藉此機會放棄你構造模組所使用的這種方法,然後在cffi或者ctypes上重新書寫模組。如果這種方法還不行的話,因為你有點頑固,那麼只有接受這樣的痛苦。也許試著在C前處理器上書寫某些令人討厭的事可以使移植更容易些。
  • 使用Tox來進行本地測試。能夠立刻在所有Python版本上執行你的測試是非常有益的,這將為你找到許多問題。

展望

統一2.x和3.x的基本編碼庫現在確實可以開始了。移植的大量時間仍然將花費在試圖解決有關Unicode以及與其他可能已經更改了自身API的模組互動時API是如何操作上。無論如何,如果你打算考慮移植庫的話,那麼請不要觸碰2.5以下的版本、3.0-3.2版本,這樣的話將不會對版本造成太大的傷害。

相關文章