🐷

Pythonが教育用途において十分だという話

2024/04/01に公開
6

Pythonが教育用途において十分だという話

今話題のPythonを教えている現役の講師です。Pythonを教える際に重視すべきだと考えている機能等について書いておきます。

dataclass / Pydantic

自分は型ヒントよりもdataclassやPydanticを使った型付けを重視しています。いわゆるクラスベースな言語の書き方が大事だと考えています。

dataclass

Pythonは動的型付け言語であり、interface相当の機能すらclassの構文で書く変わった言語です。近年Pythonの型ヒントは少しづつ充実してきていますが発展途上であることは否めないですし、何より実行時にその型であることは保証されないので、dataclass等を使った開発スタイルが依然強力だと考えています。

Python+TypeScriptというようなスタックを使う際には両言語の差に混乱するでしょう。TypeScriptでは必要に迫られた時しかclassを使いませんが、これはJavaScriptがプロトタイプベースの言語であり実行時にclassすらobjectに還元されてしまうことからこのような文化が定着していると考えられます。

一方でPythonはクラスベースの言語なので、PHPやJavaを書くときにそうするように、多くの実装をclassで書くべき言語です。実際はそうなっていないことはとても嘆かわしいことです。

例えば値オブジェクトのようなものを書きたい場合、Pythonではdataclassを使います。

from dataclasses import dataclass

@dataclass(frozen=True)
class User:
    name: str
    email: str
user = User('sigma', 'sigma@mail.com')

user.name = 'SIGMA' # dataclasses.FrozenInstanceError: cannot assign to field 'name'
print(user) # User(name='sigma', email='sigma@mail.com')

user2 = User('sigma', 'sigma@mail.com')

print(user == user2) # True

frozenによりデータがイミュータブル(*)であることを保証しています。__repr__が設定されprint()時の表現が得られたり__eq__が設定されたりします。

* Pythonのclassに真の意味でのイミュータブルはないため、この表現は正確ではないですが、上書きを制限することの意義というようなことを説明する際に他に適当な語がないためこの語を使っています。

https://docs.python.org/3/library/dataclasses.html

よくあるアンチパターンは生のdictを使うことです。

user_by_dict = {'name': 'sigma', 'email': 'sigma@mail.com'}

何がダメというか全部ダメなんですが、このパターンの恐ろしいところは例えば後から任意のキーを追加し得るところです。

user_by_dict['something'] = 'something'

これによりプログラム中には「このdictに特定のこのキーがあればこうする」というような分岐が発生したりします。そのキーが実際に追加されている個所や追加される条件はプログラムの奥深くに眠っていたりします。どうしようもないPythonのコードに遭遇した場合、まず間違いなく生のプリミティブなdictがふんだんに使われています。

別の解決策としてTypedDictを使う方法もあります。この方法もかなり良くはありますが、Pythonそのものの型ヒントの書き方がバージョンによって違うなどすることもあり、運用が面倒だという印象はあります。

https://mypy.readthedocs.io/en/stable/typed_dict.html

Pydantic

Pydanticは相当クールなソリューションです。砂漠を歩く者を引き寄せるオアシスのようにPython開発者を引き寄せています。

値オブジェクトを書く際には各値がどのような値であるべきかバリデーションを書いたりします。これは当然実行時にも評価されるため値に一定の保障を与えてくれますが、値を受け渡す度に発生する計算であり、本質的に遅いです。Pydanticは豊富なオプションを使って汎用的なバリデーションを記述できますが、バリデーションの実装はrustで書かれています。

例えばidが1以上であることを保証したい場合は以下のように書きます。

from pydantic import BaseModel, Field

class User(BaseModel):
    id: int = Field(ge=1)
    name: str

user = User(id=0, name='sigma') # pydantic_core._pydantic_core.ValidationError: 1 validation error for User
# id
#   Input should be greater than or equal to 1 [type=greater_than_equal, input_value=0, input_type=int]
#     For further information visit https://errors.pydantic.dev/2.6/v/greater_than_equal

id=1等とすると色んな情報を提供してくれるエラーが出てきます。例えばバックエンドを書くのであればリクエストのスコープの殆ど全ての区間でPydanticを使うことで、実行時エラーを頼りにしたデバッグが格段にやりやすくなります。ミュータブルである必要がなければ frozen=True にして扱います。

class User(BaseModel, frozen=True):
    id: int = Field(ge=1)
    name: str

user = User(id=1, name='sigma')
user.id = 2 # pydantic_core._pydantic_core.ValidationError: 1 validation error for User
# id
#   Instance is frozen [type=frozen_instance, input_value=2, input_type=int]

ABCとDI

PythonにはABCという冗談みたいな名前のパッケージがあります。Abstract Base Classessの略のようで抽象クラスを書くためのパッケージです。dataclassで表現するのが適切でないようなクラスにはこれを使います。

https://docs.python.org/ja/3/library/abc.html

Pythonにはインターフェースがないため、DIP(Dependency Inversion Principle)を実現するためにはABCを使います。例えばRepositoryクラスの抽象クラスは以下のように書きます。

from abc import ABC, abstractmethod
from entity.user.UserId import UserId

class AbstractUserRepository(ABC):
    @abstractmethod
    def find_all(self):
        raise NotImplementedError

    @abstractmethod
    def find_by_id(self, id: UserId):
        raise NotImplementedError

dataclassやPydanticとABCを使えばそれほど型に困ることはなくなります。classが全ての中心であるPythonにとってこれらは欠かすことのできない要素です。

DIコンテナ

特にFlask等のマイクロフレームワーク扱う上で、DIコンテナの存在は無視できないものです。サービスクラスをリクエストの度にインスタンス化するようなコードを見たことがあるでしょうか。速度的にはそれほど致命的ではありませんが、SDGsの時代に資源の無駄遣いは避けたいです。もっともPythonは多くのタスクで資源を余計に使うんですが。

Pythonはシンプルな言語だからシンプルなマイクロフレームワークを使おうというシンプルな考え方は良いとは思いますが、その方針を採用するのであれば必要な時に必要な技術要素を知っているかどうかが命運を分けます。

injector

DIコンテナのパッケージであるinjectorを使えばインスタンスを使いまわせます。3Rのreuseです。エコですね。

それ以外にも例えば抽象クラスと具象クラスをバインドすることもできます。Webバックエンドでは殆ど必須のテクニックと言えるのではないでしょうか。

バインドやリゾルブをするクラスは例えば以下のような形になります。InjectorにBinderを受け取る関数を渡しますが、その関数をclass内にstaticmethodとして書いています。

from injector import Binder, Injector
from repository.UserRepository import AbstractUserRepository, UserRepository
from service.UserService import UserService


class Dependency:
    def __init__(self) -> None:
        self.injector = Injector(self.config)

    @staticmethod
    def config(binder: Binder):
        binder.bind(AbstractUserRepository, to=UserRepository)
        binder.bind(UserService, to=UserService)

    def resolve(self, cls):
        return self.injector.get(cls)

di = Dependency()

例えばサービスクラスには以下のように注入できます。

from entity.user.UserId import UserId
from injector import inject
from repository.UserRepository import UserRepository

class UserService:
    @inject
    def __init__(self, user_repository: UserRepository):
        self.user_repository = user_repository

    def get_user(self, user_id: UserId):
        return self.user_repository.find_by_id(user_id)

パッケージ管理システムとlockファイル

「きちんとlockファイルを生成するパッケージ管理システムを使いましょう。」ということは強く教えています。

Pythonには requirements.txt を使って必要なパッケージを管理する方法があり、実際に広く使われています。現在インストールされているパッケージとそのバージョンをファイルに起こし、どこでもインストールできるようにするというものです。この方法は正直言って原始的過ぎます。友だちに書き捨てのスクリプトをシェアしたい、というような用途でなければ推奨はしません。

requirements.txt の最大の問題点は直接依存するパッケージのみが記述されていて、依存の依存のそのまた依存というような情報が一切記載されていません。そのような情報が書かれたファイルはlockファイルと呼ばれ様々な言語のデフォルトのバージョン管理システムで当たり前のように生成されます。

lockファイルには依存の依存のそのまた依存というような依存ツリーに現れるパッケージがバージョン名、ハッシュ値等と共に記載されています。同じパッケージ群の下で実行されることをきちんと保証するにはそれらの情報が必要です。

教育用に requirements.txt を使うことの是非

バージョン管理システムは現代の開発にはなくてはならないので、教育用の書き捨てスクリプトであってもちゃんとしたバージョン管理システムを使うべきだと考えています。確かに教育用のコードの多くは書き捨てではあると思いますが、 requirements.txt をその問題点についてのエクスキューズ無しに使うことは避けられるべきです。

自分はpoetryを使っています。poetryはそれほど新しくないですが、現代的なバージョン管理システムではあると思います。

クラウド実行環境

Colaboratory と呼ばれるクラウド実行環境があり、numpyなどがimportするだけで使える点も特筆すべき点として挙げておきます。

Colaboratryは実際にはjupyter notebookのための環境であり、正確にはPythonの実行環境ではありませんが、実行可能なサンプルコードとマークダウンを同時に記述する形式としては優れています。

クラウドの実行環境が存在する言語は沢山ありますが、この点においてもPythonは教育用途において必要十分だと言えると思います。

感想

PHPやJavaでも値オブジェクト等の考え方などは使いますし教育用途には十分なんじゃないかと思います。強いて言えばclassのメンバやメソッドへのアクセス制御についてのPythonの考え方は独特かなとは思います。普通に private とか書かせてほしいんですが、Pythonのクラスに完全なprivateはありません。これは Guido Van Rossum が言ったとされる「We're all consenting adults here」というモットーに基づいていて、Pythonではユーザーの意思次第でクラスを自由に拡張できるべきだと考えられていることに由来します。全然わからん。

Pythonについて色々言いたいのは分かりますが、じゃあRで書かれた保守性のかけらもないシステムを保守したいんですか?という話だと思います。Pythonで書かれた酷いコードを減らすためにPythonを滅ぼしても他の言語で酷いコードを書くだけなんじゃないですかね。どう考えてもその層はRustとかには流れない。

GitHubで編集を提案

Discussion

ruby_funruby_fun

「プログラミングの学習向け」という目的に必要なことが、Pythonで本当に簡単にできますか?

プログラミング言語を使った基礎的な書き方(文法や慣用句)を覚えたら、その次は、
データサイエンス分野でもWebアプリ分野でも共通的なアルゴリズムを学ぶための前提的な「高校までの数学の計算演習問題」を簡単なコードで計算できるという”体験”が重要でしょう。

まず、「アルゴリズムの学習って、何が嬉しいの?」という、動機付け;

# フィボナッチ数列の第n項の計算量が、(n)のオーダより早い(log(n))を体験できるコード;
require 'matrix'
p "(6) フィボナッチ数列ランダムアクセス"
[2,4,5,6,7,8,9,16,17,32,33,64,65,255,256,257].each {|n|
f=(Matrix[[0,1],[1,1]] ** (n))[0,0]
p [n,f.to_s().length, f]
}
#==> [2, 1, 1]
#==> [4, 1, 2] 中略
#==> [256, 53, 87571595343018854458033386304178158174356588264390370]
#==> [257, 54, 141693817714056513234709965875411919657707794958199867]

このRubyコードの例の場合、べき乗演算子(**)の指数部の引数が整数の場合に特化した高速アルゴリズムが存在していることや、高校の数学の計算問題としては簡単な「行列のべき乗」というアルゴリズムで、「フィボナッチ数列のランダムアクセスができる」という、アルゴリズムの有難み体験できます。

次に 高校までの数学の計算問題;

--# 多倍長整数係数の複素数の分数計算;
p "(1)",x=Rational(12345678901234567890 ,(1 - 12345678901234567891i)) -
Rational(12345678901234567891,(1 + 12345678901234567890i))
#==> ((-228623681298582551271376318164380429986
/ 11615286144559076666048468336368822614758804202921592629334943372565917420041) +
(23230572289118153332096936672737645229441400512076991074912761305743708030085 /
11615286144559076666048468336368822614758804202921592629334943372565917420041)*i)

この手計算ではヤリタクナイ計算は、Ruby言語のgenericな標準ライブラリの動的型付け機能によって、
Rational<Complex<BicInteger>>な式の四則演算や通分の過程で、Complex<Rational<BicInteger>>などの型に動的に型変換されて計算結果が得られています。
Pythonの分数classは、静的に数値型を継承したclassしか引数にとれなくて、動的に発生するComplex<BicInteger>のような型のインスタンスを保持できない、静的型付けされたライブラリ体系なので、標準ライブラリだけの1文では計算できないし、拡張ライブラリであるsympyをもってしても、通分が不完全だったりして、「高校までの数学の計算演習問題」を簡単なコードで計算できるという、体験ができないでしょう。

"再利用性のある自作ライブラリ"の体験;

さらに、次に学習で体験すべきは、
「1回だけ定義した関数に、色々な型を適用して、使いまわしできる」という、
「自作ライブラリの再利用性」の体験でしょう。

// -- # 一次方程式の解の公式"
def SolvingLinearEquations(y,a,b)
x= (y -b) / a
end

p "実数解",SolvingLinearEquations(1.0,5, 0.5)
p "分数解",SolvingLinearEquations(Rational(1,1), Rational(5,1), Rational(1,2))
p "虚数解",SolvingLinearEquations(1+1i, 5, 1.0 /(2+ 2i))
p "虚数&分数解",SolvingLinearEquations(Rational(1+1i, 1), Rational(5,1), Rational(1,2+ 2i))
p "多倍長整数の行列解",SolvingLinearEquations(Matrix[[Rational(1234567890123456789890,1),Rational(0,1)]],
Matrix[ [Rational(1234567890123456789890,1), Rational(12345678901234567898902,1)],
[Rational(1234567890123456789890,1), Rational(1234567890123456789890
3,1)] ],
Matrix[[Rational(1234567890,1),Rational(123456789,1)]] )
p "多倍長整数 複素数分数の行列解",SolvingLinearEquations(Matrix[[Rational(1234567890123456789890,1i),Rational(0,1)]],
Matrix[ [Rational(1234567890123456789890,1), Rational(12345678901234567898902,1i)],
[Rational(1234567890123456789890,1), Rational(1234567890123456789890
3,1i)] ],
Matrix[[1234567890, 0+1i]] )

/ /-- # h t t p s : //ideone.com/cJ1hIF

ところが、Pythonの場合、四則演算子が、スカラー型のclassと、行列系クラス(リストのリストに非ず)とで、異なるメソッド名が付与されているので、それらの種類ごとにSolvingLinearEquationsを 何度も定義しなくてはダメで、中途半端に「再利用性の無いライブラリ」の例を体験できるだけでしょう。

学習者向け 演習環境----------------

上述の「高校までの数学の計算演習問題」を教材にした体験学習を、決して暴走しない、ideoneのような、オンラインの演習環境で、標準ライブラリだけで、試せることが、学習向け教材としては必要でしょう。
ライブラリのバージョン管理とか、操作ミスを誘発する作業は、「高校までの数学の計算演習問題」を教材にした体験より、後で良いでしょう。
この点、Pythonだと、Pythonの利用分野でポピュラーな、numPyやsympyなどがideone.comでは動作しないので、学習者向けオンライン演習環境を探すことも面倒でしょう。

sigmasigma

回答になっているか分からないですがcolaboratoryを使えばいいのではないでしょうか。

以下utokyo-ippさんが公開しているjupyternotebookです。
https://colab.research.google.com/github/utokyo-ipp/utokyo-ipp.github.io/blob/master/colab/5/5-3.ipynb

ruby_funruby_fun

えーと、「学習者向け 演習環境として、jupyter notebookが使える、クラウド上のColaboratory を使えば良い」というのは、先の提言への回答の ひとつですね。

「Pythonが教育用途において”十分”だ」と言われたからには、
「プログラミングの学習向けの教材としてのアラユル要件を満たす」と主張したに等しく、
先に示した「プログラミングの学習向け教材としての4要件 程度の”基礎的”なことは、
 Pythonが全て満たす」という、
コースウェア開発に貢献しうるPythonの機能や仕様が”十分に揃っている”ことも、示されるのかと思います。

先に例示したコードは 各々数行で、
「初学者の1週間目は、1行のコードが書けて、2週間目なら3行まで書けて、3週間目に10行まで書けて、4週間目なら30行、5週間目なら百行書ける」という習熟度だとしても。
3週間以内に 先の演習コードを体験できるでしょう。

でも、Python言語で、同等出力ができるコードを書くのには、数百行要すので、3週間以内には、先の演習課題を解けないでしょう。

解答コードがタッタの1行のコードなのに、「難しい」とか「解らない」という人が居るという、証拠になるようなコメントも付いていますよね。

例えば、高校までの数学の計算問題の”多倍長整数 複素数分数の行列”解として、
Matrix[[((-3703703671/1234567890123456789890)-(3/1)*i),
  ((2469135781/1234567890123456789890)+(2/1)*i)]]

というのは、正解になるし、ついでに、それを浮動小数点数で表現した
「Matrix[[[-3.0000000005100002e-12, -3.0], [2.00000000061e-12, 2.0]]]」
というのも 添えるのも、okでしょうが、
Pythonのnumpyを使って後者しか計算できないのは、不正解となるでしょう。

Pythonのnumpyライブラリの場合は、浮動小数点ベースの演算に 型が拘束された 静的型付け的なライブラリなので、先に例示した、多様な型の計算ができないということが、再利用性の無さの根源です。

もし、Pythonの標準ライブラリにgenericな多様性を持たせて ライブラリ体系を再設計すれば、Ruby並みの簡素なコードを書くことができるようになるかもしれません。
でも、それでは、Pythonの互換性が損なわれてしまうので、もはや「高校までの数学の計算問題で、アルゴリズムや再利用性を体験する」という使い道には、手遅れなのです。

ご異論が在るなら、まずは、先に例示したRubyコードと 同等な出力ができるPythonコードを、各々数行、合計百行未満で、書いて見せていただきたいものです。

5週間以上Pythonを学習された方なら、百行未満のコードは書けるでしょうが、私の想定である「Ruby言語で百行未満の演習課題と同等コードは、Python言語だと8百行程度になる」となれば、易々と、Pythonコードを提示できる人は出ては来ないでしょう。

白鳥白鳥

まず前提をすり合わせることが建設的な議論をするために重要だと思います。

なのでこの場合「プログラミングの学習向け教材の要件」をすり合わせることが先決です。

ruby_funさんのコメントはその大部分がruby_funさんの考えた前提に基づくものであり、つまり大部分が現状記事に関係ない話です。

相手の主張を無視してご自身の主張をなさる行為は一般的にリスペクトに欠けているとみなされる行為であり、著者の方の主張を羊頭狗肉と侮蔑的な表現をすることは一般的に人を不快にさせる行為です。

つまりは現状ruby_funさんはzennのコミュニティガイドラインに違反し、利用規約の4条12項にも違反していると思われます。

ruby_funさんはそういった行為をやめて建設的な議論を目指された方がいいと私は思いました。

PS.
記事の内容は主に保守性の高いコードを書くためにどの言語でも利用される概念をベースとしたライブラリの紹介で私はとても素晴らしいと思いました。

タコタコ

frozenによりデータがイミュータブルであることを保証しています。

frozenはイミュータブルではありません。リフレクションによってチェックするだけです。

>>> user.__dict__['name'] = 'invalid'
>>> user
User(name='invalid', email='sigma@mail.com')

また、frozenは表層的な代入しか検査しません。frozenクラスのインスタンスの中に別のオブジェクトのインスタンスある場合に、中のインスタンスを書き換えることができます。

>>> user.profile.email = 'invalid' # 可能

Pythonではこれらを禁止することができません。

Pythonで書かれた酷いコードを減らすためにPythonを滅ぼしても他の言語で酷いコードを書くだけなんじゃないですかね。どう考えてもその層はRustとかには流れない。

私は長年Pythonを書いてきましたが、深層学習が流行りだしたあたりから明らかに酷いコードを書く人が増えたという印象を持っています。そして彼らの多くがPythonしか書けません。
初学者にいきなりRustを勉強させるのは酷だと思いますが、ある程度経験を積んだ人には使うか使わないかに関係なく、酷いコードをコンパイルエラーにしてくれるRustを勉強してほしいという気持ちはあります。

sigmasigma

また、frozenは表層的な代入しか検査しません。frozenクラスのインスタンスの中に別のオブジェクトのインスタンスある場合に、中のインスタンスを書き換えることができます。

これについてはprofileをdataclassで書いてfrozenにすることで防げると思います。

@dataclass(frozen=True)
class Profile:
    name: str
    email: str

@dataclass(frozen=True)
class User:
    profile: Profile

user = User(
    Profile('sigma',  'sigma@email.com')
)

user.profile.name = 'invalid' #error

言語機能としてイミュータブルを強制することができないのは自分も酷いなと思っています。いわゆる「みんないい大人なんだから(We're all consenting adults here)」というモットーで正当化されている部分です。

とはいえ何故Pythonにちゃんとしたイミュータブルなクラスが無いのかという説明をイミュータブルという概念を獲得する以前にすることが難しいため、ここではイミュータブルと呼んでいます。注釈でも加えておきます。