Pythonについて思うこと

みなさん、Pythonは好きですか?

この記事では、私がPythonという言語とそのエコシステムについて思うところを書いていきます。全体を通したストーリーみたいなのはなくて、トピックごとに書いています。

私のPython経験は3年弱です。Pythonについてまだまだ新米だという自覚はありますが、そこは有り余る才能でカバーしてこの記事を書いています。

静的型

Pythonには静的型がありません。型ヒントはありますが、インタープリターにとっては飾りにすぎません。

mypyとかの型チェッカーはありますが、「それさえあれば万事ハッピー」なものではなく、既存のコードを適宜書き換えないと型チェッカーでまともな結果を得るのは難しそうです。型検査を念頭に書かれていない(型ヒント付きの)コードをそのままmypyにかけても大量のエラー・警告が出てくるでしょう(ちなみに、型ヒントなしの関数はmypyのデフォルトでは無視されます)。

これは想像ですが、型検査と相性のいいコードを書くには、代数的データ型(特に直和型)とパターンマッチをカジュアルに使えるのが望ましい気がします。この方向性をどれだけPythonで実現できるのか、まだちゃんと試したことはないですが、機会があったら試してみます。

細かい話になりますが、Optional[T]None でないことを仮定して T を取り出す関数・記法が標準になさそうなのが地味に面倒です。

変数のスコープ

Pythonの関数内で定義する変数は、基本的に関数全体が有効範囲となります。たとえifやforのブロック内で代入した変数であっても、ブロックを抜けた後でも有効となります。

def f(x: int) -> None:
    if x == 0:
        y = 42
    print(y) # yを参照できる!(yが未定義の場合は実行時エラーになる)

Webをやっている人向けに言うと、「JavaScriptのvarしかないような状況」となるでしょうか。厳密にはリスト内包表記で使う変数はスコープがそこで完結するという例外はありますが(let以前のJavaScriptにも変数のスコープの例外はありました)。

同じ名前の変数は関数一つにつき一つしか持てないので、

def g() -> None:
    for i in range(5):
        for i in range(3):
            pass
        print(i) # 内側のforで上書きされたiが表示される

というコードは2を5回出力します。

まあ流石に上の例(ループ変数が被る)は「書いた人が悪い」という意見が多いでしょう。ただ、一画面に収まらない程度にコードが肥大化していくとうっかりこういうコードを書いてしまうことがあります。Pythonでは一画面に収まらないforブロックは書くなってことですね。

ところで、開発の初期段階、プロトタイピングでは、リファクタリングは後回しにして、関数が一画面に収まらなかろうがとりあえずガッと書きたくなることはありませんか?そういうことがあるのであれば、Pythonはプロトタイピングには向かないということです。

この問題の亜種として、lambdaでキャプチャーしたループ変数が全部同じ値を指すというものがあります。

from collections.abc import Callable

def h1() -> list[Callable[[], int]]:
    l: list[Callable[[], int]] = []
    for i in range(5):
       l.append(lambda: i * i)
    return l

def h2() -> list[Callable[[], int]]:
    return [lambda: i * i for i in range(5)]

上記の h1h2 で定義された i はいずれも実体が一つなので、どちらも [lambda: 16, ..., lambda: 16] が返ってきます(さっきリスト内包表記のスコープに触れましたが、スコープは狭くても結局実体は一つなんですね)。

話を戻すと、この「関数スコープしかない」という仕様は結構凶悪だと私は思っていて、(型ヒントと型チェッカーという対処方法がある)静的型の問題と違って言語仕様的にどうしようもありません(lambdaでキャプチャーする変数についてはよく使われる回避策がありますが)。対処するとしたら、変数名を手動でリネームするか、型チェッカー、linterに検出してもらう、とかになるでしょう。

ちなみに、mypyは同じ変数名で異なる型の再定義を許さないので、「mypyを導入するために変数名の書き換えが必要になる」という状況が発生します。例えば、次のコードはmypyに拒絶されます:

def main() -> None:
    for x in [1, 3, 5]: # xはint型としたい
        print(x)
    for x in ["a", "b", "c"]: # xはstr型としたい
        print(x)

他の型チェッカーはこの点もう少し柔軟なものもあるようです。

Python風の文法を持つ言語処理系が出てきたら、私は変数のスコープがどうなっているか確認することにしています。「Pythonのサブセット」を謳うものだと変数のスコープの仕様もPythonの仕様を踏襲している可能性が高いです。一方で、最近発表されたMojoはletとvarでブロックスコープの変数を定義できるようです。Mojoの設計者は「わかって」ますね。

インスタンス変数

オブジェクト指向といえば何でしょうか。そう、カプセル化ですね(これは嘘で、オブジェクト指向とは言えないMLやHaskellなどの言語にもカプセル化に相当する機能はあります。ですがここではリップサービス[誰への?]として、そういうことにしておきます)。

Pythonでプライベートなインスタンス変数を使うには、慣習として、変数名をアンダースコアから始めます。実際にはアンダースコアをつけていても他からアクセスできるので紳士協定に過ぎませんが、そこは目をつぶります。

class Foo:
    def __init__(self):
       self._started = False
    def start(self):
       self._started = True
    @property
    def started(self) -> bool:
       return self._started

オブジェクト指向の特徴をもう一つ挙げるとすれば何でしょうか。そう、継承ですね。Pythonでは実装の継承ができます。

実装の継承と、アンダースコアからなるプライベート変数を組み合わせた時に何が起こるか考えてみましょう。

親クラス Foo はアンダースコアから始めるプライベート変数 _started を使います。プライベートなので、ドキュメント化する必要もないでしょう。

子クラス Bar も、アンダースコアから始めるプライベート変数を使います。子クラスの作者も _started という変数名を使いたくなりました。

class Bar(Foo):
    def __init__(self):
        super().__init__()
        self._started = None
    def start(self):
        super().start()
        self._started = "Yes"

おっと、衝突してしまいましたね。これはよろしくありません。

親クラスと子クラス、どちらも同じ人が開発していれば、書く人の注意力次第でどうにかなるかもしれません。あるいはプライベート変数の型ヒントもちゃんと書いておけば型チェッカーがエラーにしてくれるかもしれません(型がたまたま同じだとどうしようもないですが)。しかし、パッケージを跨いだ継承を行うと、この問題を事前に回避することは不可能に近いでしょう。パッケージを跨いでクラスを継承させる例はGUIとかニューラルネットワークとか、珍しいものではありません。

この _started という変数名が衝突する例は、私が実際にとあるGUIフレームワークで遭遇したものです。

Pythonでは幸い、インスタンス変数名をアンダースコア2つで始めると名前がマングリング(変換)され、衝突を回避できる確率が上がります。クラス名まで被るとどうしようもなさそうですが。

ここでの教訓は「よそのパッケージで定義されたクラスから継承するときは必ずアンダースコア2つのインスタンス変数名を使え」ということになります。

この問題は、オブジェクトをインスタンス変数名から値へのハッシュテーブルとして実装している言語全般で起こり得ます。私は動的型オブジェクト指向言語には比較的疎いので、この問題に呼び名がついているのかは知りません。

JavaScriptにはこの問題を回避できる、ハッシュ記号 # から始まる真にプライベートな変数が導入されたと聞きます(あるいはSymbolをキーに使う方法もあるでしょう)。Pythonには名前マングリングがあります。Rubyはどうなんでしょうか。

Global Interpreter Lock

現代のCPUの性能をフルに活かすにはマルチコアの活用が不可欠です。ですが、CPythonにはGlobal Interpreter Lock (GIL)という仕組みがあり、マルチコアの活用が困難です。

Pythonにもマルチスレッドはあり、IOバウンドな処理では有効かと思うのですが、IOバウンドな処理だったら(APIが対応していれば)最近導入されたasync/awaitでいいのでは、と思ってしまいます。CPythonのスレッドはasync/await以前の資産を使うための代物なのでは?と。

Pythonでマルチコアを活用する方法は一応あります。その方法の一つがマルチプロセス (multiprocessing) の利用です。ですが、マルチプロセスはオーバーヘッドが心配ですし、パッケージによってはマルチプロセスを想定しておらず回避策が必要なものもあります。実際、試したGUIフレームワークの一つがマルチプロセスを想定していない感じでした。別種の問題として、Windows(WSL2を含む)だと(Windows用ドライバーの制限なのか)CUDAの共有メモリーが使えない、というものにも遭遇したことがあります。

GILを回避する他の方法としては、C拡張を書く、numbaを使うなどがあります。

速度

Pythonはスクリプト言語の中でも遅い方らしいです。私はベンチマークを取ったことはないのでどこかの受け売りですが。

速度が必要な箇所はC拡張(numpyも含む)やnumbaに追い出すのがPython流でしょうか。そういう回避策があって私はそこそこ満足しているので、Pythonの素の遅さはそこまで致命的な問題だとは思いません。まあnumpyを使ったりnumbaを使ったりC拡張を書くのも無手間ではないので、素のPythonが速いことに越したことはありませんが。

numpy

Pythonと他の汎用言語を比べたときに特筆すべきなのがnumpyの存在でしょう。単に準標準ライブラリーとして存在するだけでなく、numpyのために用意されたような構文もあります(行列乗算の @ とか)。

numpyを使うとPythonのくせにそこそこの速度の数値計算プログラムを書けたりします(書いてて思いましたが私はnumpyと他の言語の比較を真面目にやったことがないので「そこそこの速度」という部分の根拠は薄弱です。素のPythonより速いのは確かですが)。ただ、「ループや添字を使った(遅い)コードをnumpyの性能が出るように書き換える」という作業は私にとっては快適なものではありませんでした。式を見ると配列のshapeが浮かび上がる死神の目が欲しいです。

さっき静的型について書きましたが、numpyに限らず多次元配列・テンソルを使うコードを書いているとshapeも静的に検査して欲しくなります。検査とまではいかなくても、ドキュメントの一環として書きたい・書かせたいです。実際ndarray型の型引数に(dtypeに加えて)shapeを書くための項目があるようですが、現時点では仕様が固まっていないように見えます。

linter

個人的にはlinterというのはあまり好きではありません。口うるさい割に実益が少ない気がするのです。まあPythonには先に書いた変数のスコープの問題があるので、面倒でも導入した方がいいのかもしれません。

Pythonの話から逸れますが、一つ昔話をします。10年近く前、私がR⚪︎byを使った開発に参加していた時、自分が担当するコードにRub⚪︎copというlinterの導入を試みました。チームとしてではなく個人で試しに使ってみるという感じでした。

ある日、Ruboc⚪︎pがコード中の has_key?key? に書き換えることを提案してきました。機械を疑うことを知らなかった私はその通りにしました。すると、そのコードは動かなくなってしまいました。

そう、R⚪︎bocopはレシーバーがハッシュテーブルだと思ってその提案をしたのですが、実際は違っており、そのオブジェクトに has_key? はあっても key? はなかったのです(ちなみに標準添付ライブラリーのオブジェクトです)。これはRu⚪︎yに静的型があれば防げたはずの悲劇です。今の⚪︎ubyではマシになっているのでしょうか?

ともかく、当時の私はR⚪︎b⚪︎c⚪︎pはない方がマシな代物であると判断し、使うのをやめました。設定をカスタマイズすれば危険な提案をやめさせることはできたかもしれませんが、そこまでしてRub⚪︎c⚪︎pを使い続ける意義が見出せなかったのです。

こうして私は動的型言語のlinterに不信感を抱き、今に至ります。みなさんはもっと公正な目でlinterの良し悪しを判断してください。

ライブラリー選定

Pythonの長所は豊富なエコシステムです。同じ用途のパッケージが複数出ていることもあるでしょう。そういう時の選定基準は完成度とかになると思いますが、型ヒントとの相性も選定に加味すると良いでしょう。

例えば、Web APIを書くならFastAPIが良さそうです(私自身はまだ使ったことはありませんが)。

依存パッケージの管理

みなさん、 requirements.txt 書いてますか?それともPipenvやPoetryを使っていますか?

個人的な感触としてはPoetryは結構良さそうですが、なんか遅いように思えるのと、GPU依存のパッケージを扱うためのベストプラクティスがよくわかっていません。知ってたら教えてください。

煮ても焼いても食えないが捨てるわけにもいかないコード

自分がこれから新たに書くコードであれば型ヒントなどを駆使して堅牢なコードを書けるでしょう。しかし、時には他人が、あるいは堅牢な書き方を習得する前の自分が書いたコードを使用する必要があることもあるでしょう。

そのコードが自分の管轄下にあり、工数も取れるのであれば、型ヒントを加えたりリファクタリング等を行なって堅牢なコードに生まれ変わらせることができるかもしれません。しかし、自分の管轄下にない、もしくはリファクタリングの工数が取れない場合はどうしたらいいでしょうか。

一つのやり方は、既存のコードをブラックボックスとして扱い、いい感じにラップしてやることです。こうすることで、汚い部分をシステムの残りから隔離できるでしょう。もっと言うと、ラップの際にプロセス間通信を使ってやり取りするようにすれば、システムの残りの部分を同じインタープリターで動かす必要も、そもそも同じ言語で書く必要もないかもしれません。

堅牢なコードを書くコツ

堅牢なコードを書くコツについて、私から言えるのは次の2点です:

  1. 例えば、Pythonを避ける。Pythonの資産が必要な場合は、適当にラップしてRPCとかで呼び出す。
  2. どうしてもPythonを使う必要がある場合は、型チェッカーでガチガチに固める。CIも設定する。

噂によると、最近は「ロバストPython」という本も出ているらしいです。

あと、数日前にこういう記事が出ています:

Pythonを置き換える言語はこうあるべき

Pythonに対する愚痴は、「Pythonを置き換える言語はこうあるべき」と言い換えることができます。「こうあるべき」を具体的に書くと、

  • 強い静的型付け。
  • ブロックスコープのローカル変数を定義できる。
  • GILがなく、マルチコアの活用が容易である。
  • 高速である。ネイティブコードを生成できる。
  • Numpyに相当するものがある。書き心地も似せる。forで書いても遅くならなければ尚よし。
  • Pythonの資産にアクセスできる。

となります。最近どっかで見たような文句ですね。Mojoが謳い文句通りに仕上がればかなり良さそうだと思っています。


Pythonについて思うこと” に1件のフィードバックがあります

  1. 名前はまだない

    似た話ですが、最近静的な型を扱わない言語が増えて、バグの温床になってる気がするし、そういう案件を引き継いでストレスためている中年エンジニアです。

    PHPやJavaScriptにも変数の型宣言もなく、更に構造体がない代わりに連想配列を多用されるので、案件を引き継いだものとしてはソースやデータ代入結果を追うのが困難。

    なぜ流行っているのかがわからない。

    Pythonは「可読性がよい」との触れ込みもあったが、こちらも静的型宣言が無いとなると色々ありそうだなぁ~

コメントは停止中です。