👻

Pythonの制約の緩さが問題を引き起こす

2024/04/05に公開

SNSでは約1年前の「Python滅ぼす協会に入会したい」という記事が掘り起こされ、過激な表現もあって大きな話題になっている。一方で、この記事への反論と思われる「Pythonが教育用途で十分だ。(初心者は)Rustに流れない」や「Python普及しろ」という記事も目にする。

しかし、元の記事が過激な表現を用いていることもあり、なぜPythonが嫌われているのか、ということがPython擁護者には伝わっていないと感じる。そして、反論もまたPython批判者に刺さるものとは思えない。

私は、無名関数が lambda x: ... であることや、条件演算子を a if b else c と書くことなどがPythonの問題の本質とは思わない。

Pythonの問題点を集約すると、それは「制約できないこと」だと考える。

型ヒントは動的型付け言語のデメリットを補うには不十分

Pythonが型ヒント(PEP 484)を導入していることからも、一部を除き、多くのPythonプログラマーはコード中で型を示すことの必要性は理解しているだろう。

しかし、型ヒントはあくまでフォーマットの決まったコメントでしかない。以下のコードを見てほしい。Pythonプログラマーであるあなたは、 data に対してどのような型をアノテーションするだろうか。

import json

data = json.loads("""
  {"id": 0,
   "message": {"subject": "hello", "body": "Dear ..."}}
""")

この例のようなケースでは Any に逃げてしまうのではないだろうか。
型ヒントは mypy などの外部ツールでチェックはできるが、完全ではないし、すべてのライブラリの作者に型ヒントを強制することはできない。

さらに、次のケースではどうだろうか。

import json

with open('input.json') as fp:
    data = json.load(fp)

x = data['id']

こうなると x がどんな型であるのかは input.json を見なければ分からないし、そもそもこのコードが正しいものであるかは実行してみるまで分からない。このようなケースで型ヒントは何も役に立たない。

静的片付け言語のRustであれば data に対して型を指定するのは容易だ。

use serde::Deserialize;

#[derive(Deserialize)]
struct Message {
    subject: String,
    body: String,
}

#[derive(Deserialize)]
struct Record {
    id: u32,
    message: Message,
}

let data: Record = serde_json::from_str(r#"
  {"id": 0,
   "message": {"subject": "hello", "body": "Dear ..."}}
"#)?;

Pythonでも、外部のライブラリを使用すればJSONをdataclassとしてデシリアライズできるだろうが、標準ライブラリでJSONのデシリアライズをサポートしている以上、多くのPythonプログラマーは標準ライブラリを採用するのではないだろうか。

組み込み関数や標準ライブラリですら型を説明していない

Python の subprocess モジュールの run() 関数の説明を見てほしい。

subprocess.run(args, *, stdin=None, input=None, stdout=None, stderr=None, capture_output=False, shell=False, cwd=None, timeout=None, check=False, encoding=None, errors=None, text=None, env=None, universal_newlines=None, **other_popen_kwargs)

https://docs.python.org/ja/3/library/subprocess.html#subprocess.run

これを見て、それぞれの引数に何を指定すれば良いか分かるだろうか。下の方の説明を見ると、 stdin には PIPE という定数のようなものを指定できることが分かるが、他のものを指定できるのか、指定した時に何が起こるのか、ドキュメントを読むのが苦手な私には全然想像できない。
Pythonの標準ライブラリはこのように、何もわからないものばかりである。

Rustの std::process::Commandstdin() と比較してほしい。

pub fn stdin<T: Into<Stdio>>(&mut self, cfg: T) -> &mut Command

https://doc.rust-lang.org/std/process/struct.Command.html#method.stdin

cfgStdio を指定すれば良いことは一目瞭然である。丁寧にExampleまで書いてある。

動的型付け言語だからといって、何を指定してもエラーなく動くわけではない。誤った型の値を渡せば当然エラーになる。どうせエラーになるのなら、最初から型を制約してほしいものだ。

変数の不変・可変を制御できない

Pythonでも dataclass(frozen=True) を使えばイミュータブルを実現できるらしい。

from dataclasses import dataclass

@dataclass(frozen=True) 
class A:
    value: int

x = A(6)
x = A(3) # OK

x はイミュータブルではなかった。 frozen=True のどこにメリットがあるのだろうか。 frozen=True はクラスの特殊メソッドを使ってメンバーの変更を検知するに過ぎない。Python擁護派は変に反論せず、Pythonではイミュータブルを実現できないということを受け入れてほしい。

また、Pythonでは下のRustのコードのように、変数ごとに不変と可変を制御することができない。

struct A {
    value: i32,
}

let x = A { value: 6 };
x = A { value: 3 }; // コンパイルエラー

let mut x = A { value: 6 };
x = A { value: 3 }; // OK

少し話が逸れるが、Pythonのクラス周りでは問題が他にもある。

@dataclass
class B:
    value: int

@dataclass
class A:
    b: B

a = A(B(6))
b = a.b
b.value = 3

print(a) # A(b=B(value=3))

a が変わってしまった。

Pythonではすべてが shared_ptr で包まれていると思ったほうが良い。ところで、プログラミング初心者がポインタを理解しているとはとても思えない。Pythonは初心者に優しいらしいが、これを初心者に正しく説明できるだろうか。

Rustでは RefCell などの内部可変性パターンを使用しない限り、このような問題はまず起こらない。 a を不変な変数として宣言すれば、どこで何をしようが基本的に a が変わることはない。

制約の緩さが問題を引き起こす

Pythonは何もかもが緩い。その緩さは、プログラミングを勉強して間もない初心者が小さなプログラムを書く際には便利なものかもしれない。

しかし、本格的にPythonでプログラムを書こうとすると、以下のような問題が生じる。

  • 引数に何を指定できるのかが分からない
  • 実行してみるまでコードが正しいのか分からない
  • 変更してはいけないものと変更して良いものを区別できない
  • 変更したつもりがないのにいつの間にか変更されている

これらは型ヒントや frozen=True といった小手先の方法で部分的には解決できるかもしれないが、完全ではない。

We're all consenting adults here

一部のPython擁護者は、このフレーズを聖書の一節であるかのように引用し、Pythonの問題点に目を瞑ろうとする。しかし、このフレーズがPython信者以外に刺さる言葉であるかは、書く前によく吟味してほしい。

間違いは子どもが起こすとは限らない。大人であっても間違えることはあるのだ。

Discussion