C拡張と共に乗り切るPython 2→3移行術

末田卓巳氏(以下、末田):みなさんよろしくお願いします。本日は、「C拡張と共に乗り切るPython 2→3移行術」というタイトルで発表いたします。

まず自己紹介をします。私は末田卓巳と言います。

GROOVE X株式会社という会社に勤めておりまして、PyCon JPに来ていながら、実は社内ではLinuxとかFPGAをおもに担当しています。

一応Pythonはちゃんと書くので、社内では別に「まだPython 2.7使ってるんですか?」みたいな話を、あおりじゃなくて、ここを2.7から3にこうやったらできるんじゃないですかとかと相談したりということをやっております。

あとはPython歴としてはだいたい5年で、学生の時から書いているんですけれども、初めて入門した時は3.3でした。だから最初からPython 3です。

あと技術評論社の「WEB+DB PRESS」という雑誌があるですけれども、今年の4月にこちらで執筆しまして、去年のPyCon JPの、私を含めて参加者3人で書いたという、けっこうおもしろイベントでした。

Pythonでよく書くこととしては、例えば便利な道具をつくるとかよくあるやつです。あと言語機能を活用したPythonモジュールをつくるとか。あとはメタプログラミングとかを趣味としています。

本日の流れなんですけれども、まずPython 2はどういうふうに終わるのかという話をしたあとに、GROOVE X社内の事例を紹介しまして、そのあと拡張モジュールの話、さらに実際にモジュールを2/3互換にしてからまとめに入るという感じです。

さっそくいきましょう。

Python 2はいつ終わるのか?

まずPython 2はもう終わるというのはみなさんさんざん聞かれていると思うんですけど、改めて言っておきます。

来る2020年、Python 2のサポートは終了します。いいですかみなさん。終わりますからね。

2020年に終わる終わるとは言われていますが、少し具体的に掘り下げましょう。

いつ終わるのかということなんですが、最初に2020年と告知された時は、2020年としか言われていなくて、具体的な日付はありませんでした。今年の3月に確かIssueか何かが立って日付が確定しまして、それが1月1日と。

おお、最初かみたいな(笑)。年末でもいいのになと少し思ってしまったんですけど、ハッピーニューイヤーで終わるってなんやねんって感じしますよね(笑)。

個人的にはもやもやした思いを抱きつつも、今年の3月に決定しまして。Python.orgとしてのサポートは終わるんですが、OSとかディストリビューションによってはサポート対象のリポジトリに入ってしまっているからまだサポートするよみたいなものもあります。

例えばUbuntuの18.04はLong Term Supportなので、長いんです。2023年ですよ、すごいですよね。もうオリンピックも終わって3年経ちましたねみたいに言ってる頃ですよ、本当に。

あとRHEL 7とか、これは商用のLinuxなのでしかたない部分もありますが、2024年とすごく長いんです。

内心としては、できる限り早くPython 3に移行したいとめちゃくちゃ思っていると思うんですけれども、でもなかなかそうはいきません。

実情がとにかく許さないんですよ。例えばプロダクト全体でPython2をがっつり使ってしまっているパターン。もうただ単に物量が多い。

あと、このコードに依存している別のPython 2のコードがあると。ライブラリなどを自分でつくっているパターンがそうですよね。

あと社内の士気がいまいち高まらなくて、「Python 2でよくね?」みたいなのがけっこうあると(笑)。気持ちは本当にわかるんです。だって情報が多いですもんね、ネットでググると。楽ですから、そっちで書きたいと思いますよね。

でも、うちのケースではこれらも含むんですが、これらだけではなくて、もう1つ困難が待ち受けていました。それがズバリCで書かれた拡張モジュールがあったということなんです。

GROOVE Xの事情

ここから少し社内の紹介を軽くさせていただきます。なぜそれに至ったか、背景を説明しなければいけません。

弊社ではロボットをつくっています。LOVE×ROBOTで「LOVOT」というすごいかわいい名前なんですが、ロボットをつくっている以上は、まずクラウドみたいに金で殴ることができないんです。パーツを変えると単位が億になってしまうので、それはできないんです。

だから、限られた性能の中で、できる限り無駄やボトルネックをなくさなければいけないなど、とくに低レイヤーで、実装の楽さよりも高速な動作が必要とされます。

また、みんなが使うライブラリになると、それを使って書いたアプリがいっぱいあるわけですから、1つのモジュールにたくさんのアプリが依存することがあります。

これらに悲しくも該当してしまったのが、共有メモリモジュールというやつだったんです。

この共有メモリモジュールとは何かと言いますと、下に書いてあるようにセンサやマイコンとやりとりする値を格納している共有メモリという領域があります。

そこに例えばアクチュエータに伝える「何度まで曲がってください」という指示を書いたり、逆に外からやってくるセンサのデータがそこに書き込まれて読んだりするわけです。

ここがボトルネックになると、あらゆる動きが詰まってしまって、そして性能に直撃する。だから、多少コードが難しくてもとにかく早いほうがいいとか、その他いろんな理由により、CのAPIをまず実装して、そこから各言語向けのバインディングをはやすという戦略をとってきました。

あと、なにより低レイヤーなわけですから、ロボットの制御に不可欠なんです。だから、多くのアプリが依存するという特徴があります。

データの流れとしては、Pythonのアプリがあって、そのモジュールを介して共有メモリにアクセスし書き込まれた値を、マイコンとシンクしてくれるdaemonがいて、それがなんらかの物理層を通じて値を送ったりもらったりしているという流れになります。

同じ場所に2つのプログラムが値を書いたら大変なことになってしまうので、単純なロック機構をつくったりという部分をCのAPIでまず実装していて、さらにそこへのアクセスを提供するC++とGoとPythonのバインディングがあります。

今回の場合はPythonのバインディングの話です。Call stackで軽くどういう流れで処理が伝播してくのかを見ていきたいと思います。

まずPythonのプログラムが一番上の層にあって、そこからlovot_shm_py_open。これはPyObject* self, PyObject* argsと書いてあるので察しがつくかなと思うんですけれども、これはPython側から見えている関数です。

selfにはselfが入っていて、argsの中には渡されてきた引数の列が入っています。

それをPythonバインディングのコードの中で、例えば引数の数が合っているかとか、種類が合っているかとか、validateしたあとに、改めてCのAPIのlovot_shm_openというやつに渡します。

その結果が返ってきたらPythonバインディングの中で、またPyObjectというPythonから使えるオブジェクトに包んで返してあげる流れになります。

拡張モジュールの光と影

この拡張モジュールには光と影があって、具体的に説明していきたいと思います。まずPython.orgのドキュメントを読んでみます。

「Cプログラムの書き方を知っているなら、Pythonに新たな組み込みモジュールを追加するのはきわめて簡単です」とあります。ほう。

(会場笑)

「新しい組み込みオブジェクトの実装、そしてすべてのCライブラリ関数とシステムコールに対する呼び出し、ができるようになります」。なるほどなぁ、カッケー! みたいな。

(会場笑)

かっこいいですよね。システムコールという言葉がすごくかっこよくないですか? 中学の時なんか、「システムコール発行してファイル開くから」とか言ってましたよ(笑)。懐かしい。

(会場笑)

なんですけど、「C拡張のインターフェイスはCPythonに固有のものであり、これによる拡張モジュールはほかのPython実装では動作しません」という説明書きもあるんです。どういうことかというと、簡単にいうとPyPyとかでは動かないという話です。

あと、Python 2からPython 3にいくにあたって、「変更がたくさんあったので」ってなんかすごい気持ちを感じますよね(笑)。なんかいっぱいあったんだろうなという。

「Python 2のAPIを無傷で済ませることはできませんでした」という、すごく痛みを伴う文章なんです。要約すると、拡張モジュールのAPIはメジャーバージョン間で互換性がありませんということです。

Python 2/3を互換にするには、必ず改造が必要になるということがわかります。もうこれを知った瞬間にうわーと思ったんですけれども、大変そうですけどもやるしかないですよね。これでお金をもらっているわけですからね、私も。

Python2/3を互換化する

じゃあ具体的に何をやっていくかというのを説明していきたいと思います。

ここからは、さっきもれてしまっていたスライドなんですが、サンプルを上げました。さっき言ったlovot_shmというのを実際に見てもいいんですが、いろんなノイズがいっぱいあるので、超最低限のものにしました。

先ほどツイートしたので、そこからリンクで飛んでいってもらえれば。

この中身を説明すると、まずhelloworldというパッケージがあります。その中は拡張モジュールを呼ぶパッケージになっています。init.pyだけですね。

あとsetup.pyと、ソースディレクトリの中にhelloworld.cがあって、これは拡張モジュールそのものになります。さっきでいうC APIです。

このhelloworld.cというやつがあとでコンパイルされて、libhelloworld.soになって読み込まれます。

これらのソースコードをざっと見ていきましょう。

まずhelloworld.cなんですが、これは先ほど言っていた拡張モジュール本体です。この中ではPyObjectなどを受け取って、PyObjectを返すという処理が書いてあるんですが、この中には2つPython側から見える関数が定義してあって、1つ目はhello関数、もう1つはhello_hex関数です。

例としてわざとらしくつくっているんですが、1つ目のhello関数は実行するとHello, World! という文字列をただ返します。

もう1つのhello_hexは、16進数にHはないので、バイト列としてFEEDCAFEというFE、FD、CA、FEという16進数の4バイトの並びを返す関数になっています。

下に行きまして、モジュールの中にどんな関数が入っているのかを定義してあるやつが、PyMethodDefというstructの配列になっていて、helloという名前でhello関数、hello_hexという名前でhello_hex関数があると。実際にはvalidateするんですが、可変長引数を受け取るのがわかります。

最後のNULLはターミネートしているというか、最後にこれを入れることによって、配列の終わりがわかるみたいな感じのダミーです。

そのあとにinitlibhelloworldというのがあるんですが、実はこのinitlibhelloworldはこういう名前じゃないといけません。

実はCの関数って、コンパイルする時にC++とかと違ってマングリングが入らないので、シンボルの名前を見てやれば、あとからこの関数を見つけることができるんですが、Pythonの中ではimportする時にこの関数名を目印に拡張モジュールのimport処理を走らせるので、名前はこれでなければいけません。

その中で、Py_InitModuleというのがあって、これはPythonのAPIです。それにlibhelloworldという名前を渡して、実装されている関数を渡してやることで、初期化が終了します。

Python側のコード

次は、今度はPythonのコードなんですが、Python側から呼ぶやつです。先ほどつくったlibhelloworldを最初にimportします。

そしてimportしたあとにhelloとhello_hexという関数をつくっています。もう見た目でわかりますよね。printのあとにかっこがないので、これはPython2のコードなんです。あえてこうしました。Python2.7だとかっこをつけても動くんですが、あえてなくしました。libhelloworld.helloという関数を実行すると、さっき書いてあったhello関数が実行されて、その結果がprintされ、文字列が画面にポンと出ると。

次のhello_hexは、そのままだとバイナリが返ってきてしまうので、それをreprを使って、human-readableなかたちに変換してprintしています。

そういったものが書いてあって、その次がsetup.pyです。普段書いているものとほとんど変わりません。

みなさんが見慣れないと思うのが、このext_modules=[module]と書いてあるところです。

これは「C拡張はこういうのがありますよ」と書いてあるところです。これはlibhelloworldというのがあって、その構成しているソースコードがソースのhelloworld.cというのがあるという感じです。

リストになっているのはあとから全部コンパイルしてリンクするからです。このモジュールをext_modulesに渡すと、拡張モジュールとして登録ができます。

pipでインストールする時にビルドしてくれるようになります。

Python 2で実際にインストールするには、クローンしていただいて、Python2(2.7.15)が使える状況でpip install -eしてやると入ります。

そのあと、Pythonのインタプリタを起動して、import helloworldとやって、そのあとhelloworld.hello()とやるとHello, World! と出るし、hello_hex()とやると\xfe\xed\xca\xfeと出るのがわかるかと思います。このようにおおよそ想定した動作をします。

無加工でもSuccessする?

これをまずそのまま3.7で動かしたらどうなるのかやってみます。

実はそのままでもなぜかSuccessしてしまうんです。びっくりですよね、これ(笑)。確かによくよく考えてみたら、コンパイルする時にシンボルが何があって何がなくてとやるのは難しいでしょうから、うまくいってしまうんだろうなという感じなんですけど、動くわけないんです。

これを実際にimportしてみようとすると、まずSyntax Errorが出ます。もうおなじみです。printが関数ではなくて文なので、まずここでSyntax Errorが起きます。

2/3を互換化するにあたって、まず修正するのがPythonのコードです。

今回の場合は__init__.pyしかないので簡単ですし、それに実質print文しかないので、from future import print_functionとやって、そのあとprint関数に置き換えてやることで対処できます。

ちなみにPython 2.7向けに実装されているやつなら、futureからimportしなくてもprint関数が使えるので、上の行はなくてもかまいません。

ではリベンジしましょう。Import!!!

あれ、なんか長くなってません? Tracebackって書いてある(笑)。え、なんだこれって感じなんですが、右にいくと、Symbol not found: _Py_InitModuleと出ています。

頭がアンスコなので、中のAPIなんです。知らんがなって感じなんですけど、要はなにか必要なものがないと。

これは何がダメかと言いますと、まず2と3だと拡張モジュールの宣言と初期化の方法が違います。

そしてPython 3.7側でまずモジュールを認識できていないんです。だからsoを見つけていい感じに読み込むことはできるんだけど、目的のものがないと言われるという感じですね。

宣言と初期化の仕組みの違い

この宣言と初期化の方法が、2と3だとどうなっているかです。まず2から説明します。

2だとvoid initのあとにモジュール名が入る関数があって、その中でPy_InitModule(”モジュール名”、PyMethodDef*)を実行して、モジュール名のあとにモジュールの関数列を渡してやると、初期化が完了します。

けっこう簡単なんですよね。

一方、3になると、PyModuleDef構造体のインスタンスをつくり、PyMODINIT_FUNC PyInit_libモジュール名(void)というシグネチャの関数の中で、PyModule_Create(PyModuleDef)に渡し、PyObjectを返す。

見た目が全然違うんですよ。

これがビフォーでこれがアフターなんですよ。えー⁉ ってなるじゃないですか。一から十まで違うんですよね。流用して動くわけないですよ、そりゃ。

こんなに全然違うのに、2と3両方で使えるようにするにはいったいどうすればいいのか。でも、ゆっくり考えてください。我々が今ターゲットにしているのはCです。あるじゃないですか、Cには。プリプロセッサが! そう、プリプロセッサを使います。

プリプロセッサを使う

どういうことかというと、PY_MAJOR_VERSIONというマクロがあるんですよ。あれを見ると、PY_MAJOR_VERSIONの中に、実際にメジャーバージョンの番号が入っているんです。

それを見ることによってコンパイル、正確にはコンパイルが走る前のプリプロセスの時に、今コンパイルしようとしている状況が、Python 3なのか2なのかを判別できます。

今回>= 3と書いてあるのは、一応将来的に変更に対してトレラントになってほしいからという意味を込めてます。この#if、#else、#endifを使うことによって、コードそのものを切り替えることができるようになるわけです。

もう力なんです。最初の2ステップ目から力で、もう勘弁してほしいなという感じなんですけれども。

まずPY_MAJOR_VERSIONが3以上だったら、さっき言っていたすごい長いのを実行して、もしそうじゃなかったら、Python 2のやつを実行する。簡単ですね。

最初にドンと見せられると、なんか激しいなとなるんですが、やり方としてはすごく簡単です。

ではリリベンジします。

まずimport helloworld。

import成功! やったー。何も言いませんね。

次はhelloworld.hello()。

おおー、Hello, World! 出ました出ました。ちゃんと返ってきました。

最後に残るはhello_hexなんですけど、あー!

UnicodeDecodeErrorですよ! みなさんおなじみのあいつですよ。そう、出てくるんですね。

しかもすごくつらいのが、Tracebackは全然情報がないんですよ。Pythonで書かれたコードのprint文のところが出て、うんわかるわかるとなるんですが、そのあとの情報が一切ありません。

せめてこのライブラリに入りましたぐらいは教えてほしいんですが、とにかく情報がないんです。

エラーの原因を探る

おーマジかーとなりますが、ここで1回クイズをやってみましょう。さっきのhello_hexというCの拡張モジュールの関数を1個取り出してきたんですけれども、この①、②、③、④の中のどれかが怪しいです。

誰にしようかな。じゃあそこの白いシャツの方。

男性:本当に見当がついていないんですけど。

末田:当てずっぽうでいいですよ。

男性:4。

末田:拍手!

(会場拍手)

末田:そう、4です。そのとおりなんです。

Py_BuildValueというやつがいるんですけど、こいつが実は処理の結果をオブジェクトを通して返してあげる部分になるんですが、ここのフォーマットの指定を見てもらえると、s#になっているんですね。

これはchar*の配列があったら、その長さを指定して、Pythonのstrにして返すというフォーマットなんです。これ、だんだん臭う感じがしてくるのがわかります?

改めて見ますと、Python 2のstrはゆるふわバイト列なんです(笑)。Python 3のstrはUnicode文字列なんです。つまり、0xFEEDCAFEの0xFEの時点で、もうUnicodeとして解釈できていないんですよ。0xFEというのがコードとしてありえませんから、無理ですと怒られます。

じゃあこれをどうやって直せばいいかと言いますと、差分で出すとこんな感じです。

s#の部分をy#に置き換えてあげます。これはPythonのリファレンスを見ると、2にはなくて3にしかない記述になります。

どういうことかというと、s#の時はchar*の長さを指定してPythonのstrにして返すんですけど、y#の場合はbytesにして返すんです。

だから、仮に入っているbyte列がvalidなUnicodeの文字列でなかったとしても、任意のbyte列として返してくれるというふうになります。

リリリベンジします。いきますよ。といってももうスライドはつくられたやつですけど。

まずimport helloworld。

できます。成功! そのあと、Hello, World! も成功します。

そのあとにhello_hexと。

おー! やった。ありがとうございます。

これも頭にbとついているので、byte列だということはすぐにわかりますね。escapeで\xがあるのでわかりにくいですけど、fe、ed、ca、feと入っているのがわかります。

Python 2→3に移行する3つのポイント

じゃあ最後にまとめに入りたいと思います。今回見つけてきた特徴は3つあります。

まずPython 2/3で互換性のない文法とかモジュールがあります。そいつらはfutureで対処できるやつはfutureでシャッと置き換えてもいいですし、sixとかでimportを抽象化してもいいですし、もう少しアグレッシブな書き換えをしてくれるpython-futureというのも使えます。

ほかにももっといろいろありますが、そういったもので、まずはPythonの段階で互換性を確保してやる必要があります。

そしてPythonの段階で互換性を確保したら両方でimportできるようになるので、今度はモジュールの初期化の部分を変えてやらないといけません。モジュールのエントリポイントの定義がまったく異なる部分があるわけです。そこをプリプロセッサの#if、#else、#endifで乗り切ります。

これは公式のPorting Guideにも載っているのでググれば出るかなと思います。

あとUnicodeDecodeErrorは残念ながらほぼほぼ遭遇します。もうstrで全部ゆるふわのbyte列が扱えてしまうので。

strを使っている箇所があったら、それを注意深く見てください。そこがvalidなUnicode文字列かそうでないか、関係なくPython 2の場合は通してしまいますから、そこの部分にUnicodeじゃない文字列が来ていないかどうかを入念にチェックしてやったほうがいいかなと思っています。デバッガーを使ったりしてもいいかもしれないですね。

いろいろぺらぺら話して情報量がけっこう多いので、おうちに帰ってゆっくり見てみてください。おうちに帰るまでがPyCon、あ、おうちに帰るまでだったら帰った瞬間に終わっちゃいますね(笑)。帰ってからもPyConということで。

もしこれからCの拡張モジュールで困ることがあったら、この資料が役立ってくれたらいいなと思います。ありがとうございました。

ctypesを使わなかったのはなぜか?

司会者:あと5分ほどあるので、Q&Aの時間に移りたいと思います。質問のある方は挙手のほどをお願いいたします。

質問者1:ありがとうございます。おもしろかったです。

末田:ありがとうございます。

質問者1:2つあるんですけれども、1つ目はさっきが出ていたと思いますけど、あれってprintしたりreprしなくて、代入した場合でもやっぱりエラーですか?

末田:代入する場合でもCのAPIをいったん通ってくることに変わりはないので、それは発生せざるを得ないかなと思います。確認はしていないませんが、たぶん出ると思います。

質問者1:Unicodeのオブジェクトで、byte列を格納するような表現をすることは可能なので、もしかしたらUnicodeオブジェクトとして入っているけど、reprかprintの段階でDecodeErrorなのかなという気もしたので、それのコメントです。

末田:わかりました。ありがとうございます。

質問者1:もう1つは、もしかしたら前置きでお断りされていて、僕はあとから入ってきたので見逃したかもしれないんですけれども、2/3対応する時に、C拡張ではなくてctypesを使うという発想はなかったですか?

末田:私が入る前に実はこのモジュールが存在していまして、実際、私が新しく書くとしたら例えばCFFIとかctypesを使うかなという気はしていて、ここはなんていうか過去のいきさつがあっての話という流れはあります。

質問者1:C拡張のお話だったので、空気を読まなくてごめんなさい。

末田:全然大丈夫です。それがもっともな話なので。実際書いてあるので、PythonのガイドにもCFFIを使ったほうがいいですよというのは。わかります。

質問者1:どうもありがとうございます。

司会者:ほかはいらっしゃいますか?

質問者2:お話ありがとうございました。C言語のエラーをデバッグする時に、Tracebackがまったくないというところがあったんですけれども、なんとかして原因を突き止めるヒントになるものは、先ほどのstr以外ではなにかありますか?

末田:そうですね、一番簡単な発想でいくと、デバッガーをはさむことにはなるでしょうね。この時のケースだと、ちゃんとデバッガーを使わずにここらへんだろうとやったら直ったという実情もあって(笑)。

(会場笑)

逆にそれぐらい箇所として少なかったというのが実際あるんですが、もしこれが本当に不可解でどうしようもなかったら、GDBを挟んだりとか、ちゃんと調べていませんが、たぶんpipでインストールする時に、シンボル情報をstripしないとかというのもたぶんオプションとしては追加できるはずなので、それをやった上で最適化もかけずにGDBを挟むとか、これはありかなと思います。

質問者2:わかりました。ありがとうございます。

司会者:それでは少し早いですがこのセッションを終わりたいと思います。大きな拍手をお送りください。

(会場拍手)

末田:ありがとうございました。