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が謳い文句通りに仕上がればかなり良さそうだと思っています。

これから流行る言語

新言語にできることはまだあるかい

なんとかWIMPS

最近(1ヶ月くらい前)、こんな記事が出ました:

Kotlin, TypeScript, Rust, Swift以降にみんなが話題にするような新しい言語が出てこない、それはなぜか、みたいな趣旨です。客観的に見れば「新しい言語は常に出続けている」わけですが、「みんなが話題にするような」というのが多分曲者なんでしょうね。

例え話をすると、新しい若木は常に生えてきているんだけど、大木に成長するには時間がかかるので、大木にしか興味のない人には「この8年間で新しい大木は登場していない」と判断してしまうのかもしれません。

まあ私としても、Web (HTTP) APIを書く言語とか、JSON色付け係が使う言語はもう出揃ってしまったのかもしれないという気はしなくもないですが、それでも新しい言語が登場して話題を集め(新しい物好きの集団に絞れば「みんなが話題にする」かもしれません)、一定の地位を占める余地はあると考えています。そして、そういう「これから流行る言語」がすでに登場している可能性は十分あります。

(もちろん、どこかの組織または個人が裏でこっそり開発している言語の場合はまだ表には出ていないでしょう。Rustの場合は開発開始は2006年らしいですが、一般公開されたのは2011年で、「裏で開発している」期間が5年ほどあります。)

この記事では、私が独断と偏見で選んだ、「これから流行るかもしれない、(比較的)新しい言語」を紹介してみます。流行の可能性よりも「見るべきところがあるか」という観点で選んだものも含まれます。

なお、ここで紹介する言語は私の情報網に引っかかったというだけであって、必ずしも私自身が試しているわけではありません。不正確なことを書いていたら申し訳ありません。

続きを読む

構成的代数と計算機代数をやりたい

前に「週刊 代数的実数を作る」という一連の記事を書きました。

あれの続きじゃないですが、また計算機代数周りの記事を書きたいなあと最近思っています。

せっかく書くなら、今度は構成的数学の視点を取り入れたいです。

続きを読む

新しくプログラミング言語を作るときの区切り文字

Haskellにはリストやステートメントを区切るカンマやセミコロンを行頭に置くスタイルがあります。

primes = [ 2
         , 3
         , 5
         , 7
         ]
main = do { putStrLn "Hello world!"
          ; print primes
          }

ですが、「カンマやセミコロンを行頭に置く」というスタイルは他の言語のユーザーには奇異に映るのではないでしょうか。以下、そういう前提で話を進めます。

MarkdownやYAMLでは *- を行頭に置くので、「区切り文字が行頭にある」こと自体は悪くないはずです。Haskellで代数的データ型を定義するときの | を行頭に書いても初学者の違和感は少ないと思います(たぶん)。

普通のプログラミング言語で「行頭に区切り文字を置く」というスタイルを採用するとしたら、どういう区切り文字が適切なのでしょうか?

アスタリスク *, マイナス -, プラス +

この辺はデータ記述言語とかMarkdownでお馴染みですが、普通のプログラミング言語では演算子として使いたいのではないかと思います。

行頭に置いた場合のみ区切り文字として扱うという手も考えられますが、いずれにせよ「式を複数行にまたがって書くときに行頭に演算子を書く」というスタイルとの衝突が懸念されます。インデント量で識別するか、一部の言語のように継続行を表すマークを導入することになるでしょう。

あと、どっちみちインラインでリストを書くときの区切り文字には適していないので、従来のカンマも併用する必要があるかもしれません。

擬似コード:

primes = [
         * 2
         * 3
         * 5
         * 7
         ]

縦棒 |

ML系言語のデータ型定義で使われているやつです。

縦棒は慣習的に「または」の意味で使われることが多いので、リストや逐次実行などの順序が重要な用途には違和感があるのではないかと思います。まあMLのパターンマッチみたいに微妙に順序があるケースにも使われていたりしますが。

擬似コード:

primes = [
         | 2
         | 3
         | 5
         | 7
         ]

空白

レイアウトルールを採用している言語だとそもそも区切り文字が必要なかったりします。Haskellも逐次実行のdoはレイアウトルールによりセミコロンなしで書けます。

最近のHaskellにはQualifiedDoという拡張があって、それを使うと一時的にdo記法を乗っ取れるので、リストの構築に使えたりします:

module M where
import qualified Prelude

(>>) :: a -> [a] -> [a]
(>>) = (:)
{-# LANGUAGE QualifiedDo #-}
import qualified M

primes = M.do
           2
           3
           5
           7
           [] -- 型の関係で最後にこれが必要

他の例だと、Juliaは(多次元)配列を空白や改行区切りで書けるらしいです。空白と改行で意味が変わるみたいですが。

番号

番号から始めるという手もあります:

array =
  1. "first"
  2. "second"
  3. "third"
fn main() {
1. do_something()
2. do_something_else()
3. return 0
}

が、「行を削除したり、コピペで順序を入れ替えた時に修正が必要になる」という欠点があります。

それ以外

ASCIIの記号は少ないので、演算子と被らないように選ぶのは大変そうです。

ピリオド . は自然言語だと普通文末にあるのでカンマやセミコロンと同じ理由で向かない気がします。まあプログラミング言語だとメンバー名の区切りに使われることも多いし、Swiftだと識別子の前に置けたりするので大した問題ではないのかもしれない。

primes = [
         . 2
         . 3
         . 5
         . 7
         ]

コロン : は割とアリかもしれない?コロンは型注釈に使いたい気がするけど、行頭には使わないような?

primes = [
         : 2
         : 3
         : 5
         : 7
         ]

ハッシュ記号 # はどうかな。Markdownの見出しっぽいかもしれない。

primes = [
         # 2
         # 3
         # 5
         # 7
         ]

まとめ

なんか良さそうなのがあったら自分の言語に実装してみてください。擬似コードを載せた記事を書くのでも良いです。

あと、似たような趣旨の記事が既にあったみたいです:

言語処理系コミュニティーでの協働の在り方

プログラミング言語処理系が好きな人の集まりというコミュニティーがあります。ここは言語処理系を作っている人が多く集まっています。自作言語界隈とも言えます。そこでの話題について、色々と思うところがあったので、記事を書いてみます。

続きを読む

Standard MLに対する拡張のアイディア

拡張の必要性

私は現在、Standard ML処理系であるLunarMLを開発しています。しかし、Standard MLはほぼ進化の止まった言語です。一応Successor MLという取り組みがありますが、準拠を目指している処理系は多くはありません。

LunarMLが成功するためには、言語標準という枠に囚われずに「モダン」な機能を積極的に取り入れていくことが重要だと考えられます。つまり、拡張機能です。

Standard MLに足りない機能は何でしょうか。LunarMLにどんな拡張を入れれば、使いやすい言語になるでしょうか。

過去の記事ではすでにいくつか機能を挙げ、いくつかは実際に実装しました:

実装済みの拡張機能については以下に説明を書いています:

ここではもうちょっと色々アイディアを出してみます。

続きを読む

iPhoneを買った

今更ながらiPhoneを買った。これまでの私の携帯電話はガラケーやAndroid端末だった。

私は高校の頃から15年ほどMac、つまりApple製品を使っている(iPod touchやiPadも使ってきた)。なのでこれまでiPhoneを使っていなかったと言うと意外に思われるかもしれない。

なぜiPhoneじゃなかったか、特に深い理由はないのだが、強いて言うならパソコンやタブレットと違って携帯電話に高いお金をかける気にならなかったというのはあるだろう。これまで使っていたAndroid端末も中古だったりミドルレンジのものだった。

転機となったのは、これまで使っていたPixel 3aのOSアップデートが降ってこなくなって新しいスマホを買う必要性が生じたこと、ボーナスでまとまったお金が入ったこと、などだ。

というわけでiPhone 14 Proを買った。ProにしたのはLiDARを試したかったからだ。本体のサイズは大きすぎないのが好みなのでMaxにはしなかった。

なんだかんだ言って私はiPod touchもiPadも使ってきたからiPhoneはそんなに目新しくない……と言いたいところだが、妻によると触っている時のにやつきは抑えられなかったようだ。

続きを読む