はじめに
Pythonはその構文の簡単さやスクリプト言語特有の実行のしやすさから人気を博している言語だ。
私は最近この「Python」を使って業務改善をする仕事をしているのだが、改善の規模や複雑度がそれなりに大きいプロジェクトに出会い、従来のように適当にPythonを打って使っていたところ非常に辛い思いをすることになり、中~大規模開発向けの方法を模索する事になった。
そこで、模索した結果辿り着いた、Pythonにおける中~大規模開発を行う際の、個人的なおススメ方法を共有していきたいと思う。
1.Pythonを静的型付けで開発する
数十行程度のスクリプトを作る時ならともかく、実際の業務に使うようなスクリプトを書いたり、ましてやアプリ開発までしようとすると、どうしても動的型付けのデメリットであるバグの多さ/発見しづらさが目立ってしまう。そこでまずJavaScriptにおけるTypeScriptの様に、Pythonにも静的型付けに出来るような何かを求めていた。そこで出逢ったのが標準機能「型ヒント」と「mypy」「Pylance」である。
Pythonのオプション構文「型ヒント」
まずPythonには標準で「型ヒント」という機能があり、TypeScirptのような構文で変数や関数の型を宣言する事が可能だ。
VSCodeで型ヒントを付けたPythonコードはこのように表示される。
ただし、この型ヒントはあくまでも「ヒント」であり、強制力はない。例えば、
name: str = 'Takashi'
name = 100
このようにstr(文字列型)として記述したname変数に「100」という数値を入れる事が出来る。
「なんだ、使えないじゃん」と思ったそこの貴方。VSCodeの拡張機能やツールを使えば、これを「ヒント」ではなくなんと強制力を持った型制約に出来るのだ。 こうなってくるともはや動的型付け言語としての面影は消え、ほぼ静的型付け言語として運用できる。
静的型解析ツール「mypy」
mypyは、pythonのコードを解析して型チェックを行ってくれるライブラリである。
このようにmypy {ファイル名} とするだけで型チェックを行い、エラーを表示してくれる。
しかもちゃんとimport先のファイルも含めて型チェックを行ってくれるのがGood。
例:
mypy main.py
main.py:23: error: Incompatible types in assignment (expression has type "int", variable has type "str") [assignment]
Found 1 error in 1 file (checked 1 source file)
import先もしっかりとエラー表示してくれる。(VSCodeの例)
Pylance
VSCodeの拡張機能で、コードの静的解析や自動インポート、型情報の表示など開発に便利な機能が一通り揃っている。VSCodeがおススメしてくるPython用の開発パッケージにデフォルトで入っている。
例えば、先程の例はPylanceを導入することでこのようにハイライトされる。
これにより動的型付け言語にありがちな型の設定や認知ミスによるバグを未然に防ぐことが出来る。
ただし、Pylanceの静的解析機能を有効化するにはsetting.jsonに追記が必要。
(ctrl+P,setting.jsonと入力すれば開ける)
mypyを使う場合はmypyEnabledをtrueに、pylanceに付いている静的型チェッカー(Pyright)を使う場合はtypeCheckingModeを"basic","strict"のいずれかに変更する。ちなみにmypyとPyrightは併用して使う事も出来る。
また、diagnosticModeはデフォルトで「openFilesOnly」だが、これだと開いてないファイルで型エラーが発生しても気付かないので「workspace」にすることをお勧めする。
正直、システム開発において動的型付けはデメリットでしかないのでこれらの導入は必須と言えるだろう。型ヒント、Pylance等を導入すれば、中・大規模開発をする際でも複数人で型の情報を共有でき、しかもエディタ上で補完がされるので非常に安全かつ便利に開発が可能。
Pythonが中・大規模開発に向いてないのはデフォルト機能の話であって、外部ツールを使えば容易に行えるという事はもっと世の中に伝わって欲しいと思っている。
2.Pipenvを使って環境構築の共有を簡略化
Pythonにはデフォルトで「pip」というパッケージマネージャが付いている。非常に使いやすいツールだが、デフォルトだとインストール先がグローバルだったり、scriptsがない等、npmに使い慣れた自分にとっては物足りなさを感じていた。
かつ、Pythonではローカルの環境を汚さないために「venv」というpython標準の仮想環境ツールが用いられるが、npm + node_modulesの様により手軽にプロジェクトごとの環境を作りたいと思っていた。そんな用途にピッタリなのがPipenvである。
Pipenv
Pipenvはパッケージ管理と仮想環境管理を統合したツールで、以下の特徴を備えている。
1.仮想環境管理
Pipenvを使う事でPythonの仮想環境を簡単に構築できる。
仮想環境はpipenv shell
とコマンド入力するだけで仮想環境に入れる。(無い場合は自動で作られる)
仮想環境の内容(Pythonやパッケージのバージョン等)は「Pipfile」というファイルに自動で記述される。これはpackage.jsonの様な物で、このファイルさえあればpipenv install
コマンドやpipenv sync
コマンドで簡単に定義された開発環境を構築できるので、Dockerの簡易版としても運用できるだろう。この仮想環境はプロジェクト毎に生成されるので、node_modulesの様な物と考えてもいいだろう。
仮想環境を消すときも、pipenv --rm
とするだけで簡単に削除できる。
2.パッケージ管理
npmだと、npm install パッケージ名
とするだけで、自動でnode_modulesへのインストールやpackage.jsonやpackege-lock.jsonに依存関係を出力するが、
pipenvでもpipenv install パッケージ名
とすれば同様の事が出来、仮想環境へのインストール、PipfileやPipfle.lockに依存関係を出力する。
以下、Pipfileの中身の例。
[packages]はnpmでいうdependeciesに該当し、プロジェクトに必要なパッケージ名とバージョンが自動で記載される。
[dev-packages]はnpmでいうdevDependenciesの様な物で、開発に必要なパッケージ名が記載される。これはpipenv install --dev パッケージ名
とすることで開発用パッケージとしてインストールされる。
Pipfileがあらかじめ用意されていればpipenv install
とするだけで簡単に他の開発環境を再現できる。
Pipfileと同時に生成されるPipfile.lockは、実際にインストールされたバージョンを明記した詳細な依存関係を示すファイル。これを元に仮想環境を作成する場合はpipenv sync
コマンドを使う。これにより他の開発環境を完全再現する事が可能。
3.スクリプト管理
package.jsonにおける「scripts」はPipenvにもそのまま存在しており、Pipfileの[scripts]配下に記述する事で設定できる。
実行はpipenv run スクリプト名
とする。
これにより、デバッグ用コマンドやテスト用のコマンド、exe化のコマンド等をPipfile内に記述し、開発時のコスト削減やコマンドの共有などを簡単に行う事が出来る。
3.データ構造を型で縛り、コード補完を有効活用する
これは1の応用にもなるが、型ヒントと静的解析に加え、自作のデータクラスや型エイリアスを定義することで、予測変換にプロパティ名を表示させて名前指定のミス等を防ぐことが出来る。
データ構造を扱う際、C言語の「struct」やJavaScriptの「object型」のような物がPythonにはないため、代わりに「dict」もしくは新たにclassを作成することだろう。
ただしこれらには問題がある。
dict(辞書型)の問題点
- keyが存在しないとエラーになるため、タイプミス等でエラーが頻発する。
- keyが自由に設定できる影響でVSCodeの予測変換がほぼ効かない。(別ファイルでkeyを追加したdictは特に予測変換が効かない)
- 異なるデータ型を格納する場合、型アノテーションが使えない。(keyやvalueがUnknownになる)
classの問題点
- データ構造を作るのに手間がかかる。(__init__に各種引数とself.プロパティ名を1つずつ入れるという手間)
- 同じデータ構造・値であっても、比較演算子「==」で比較すると(インスタンスが違うため)不等価扱いになる。
しかし、Pythonには別途構造体を扱うのに便利なモジュールが用意されているので、用途によってそれぞれの方法を使い分けるのがおすすめ。
1.dataclass
dataclassesモジュールをimportする事で使用できるクラス用のデコレーター。
個人的にデータ構造を扱う際は基本これで良いのではないかと思う位便利。
基本的な使い方はこんな感じ。
from dataclasses import dataclass
@dataclass
class Parts:
parts_id: int
name: str
price: float
shaft = Parts(1, 'シャフト', 120.0)
print(shaft) # -> Parts(parts_id=1, name='シャフト', price=120)
通常のクラスだったらdef __init__(self, parts_id, name, price)
といった初期化処理を書く必要があるが、このデコレーターを付けると全て自動で行ってくれる。
また、比較用の特殊メソッド__eq__、オプションで__lt__、__ge__なども作られるので、if文の中で簡単に使えたり、__repr__も作られるのでprint()で簡単に構造を確認できる。
2.Literal型 + TypeAlias
**Literal型は「特定の値」のみを受け付ける型。**enumの代わりに用いたり、dictのkeyに指定する事でkeyの値指定を安全にすることが可能になる。
また、Literal型はそのままだと型ヒントとしてしか使えないが、TypeAlias(型エイリアス)にすることでimport文で呼び出す事が可能になり、特定の値のみ許す変数を作りたい場合(特にif文で分岐させる時)に活用できる。
チーム開発を行うなら、データの種類や構造をエディタを介して簡単に共有できるように、単なるstr型を用いたif分岐や、dict単体でのデータ定義ではなくこちらを活用する方が安全でおススメ。
from typing import Literal
literal_var: Literal['定数'] #'定数' 以外の値を入れるとエラーになる変数
border: Literal['top', 'bottom', 'left', 'right'] #top, bottom, left, right以外はエラー
#TypeAliasを使うと、Literalを任意の型としてimportが可能になる
Office: TypeAlias = Literal['Word', 'Excel', 'PowerPoint', 'Outlook']
#dictのkeyに使用する事で予測外の値が入るのを防ぐことが可能。
#※ただしこのケースだとExcelやPowerPointを指定するとエラーになるので注意
app: dict[Office, str] = {'Word': './sample.docx'}
このようにVSCodeの予測変換に反映されて使いやすくなる。特に表形式のデータを扱う際に有用。
3.namedtuple
dataclassやLiteralといった型は、色々なモジュールで同じ型・構造を共有できるのがメリットだが、その場でしか使わないデータ構造(関数の返り値等)を用意する場合はやや冗長に感じる場合がある。とはいえタプルや辞書で返すとコード補完が使用できない。そんな時にはnamedtupleがおすすめ。
namedtupleはcollectionsモジュールからインポートでき、第二引数で指定した名前でアクセスできるタプルを作成する。dictやtupleと違い、classと同じように「.」でアクセスできるのでコード補完もバッチリ使える。
import先でもコード補完が効くので、複数の値を返す関数を作りたい場合、単純にタプルを返すのではなくnamedtupleを活用するとより安全な開発が可能になるだろう。もちろんより汎用的なデータ構造であればdataclassの方がおススメではあるが。
4.モジュール分割
中~大規模開発になると必ず必要になってくるモジュール分割。
これに関しては正解はないので特に紹介はしないが、個人的おススメは「レイヤー毎」→「機能毎」にファイルやフォルダを分ける方法。
レイヤー分けに関しては、世の中には色々なソフトウェアアーキテクチャパターンがあるので、それの何が最適化を選んでプロジェクト用にアレンジするのが良いだろう。パターンをそのまま採用してもしっくり来なければ、かえって読みづらい&管理しづらくなる可能性がある。
ちなみに私は「どのような配置が直感的か?」「どう分割すると読みやすいか?」を最優先に、それを一般的なアーキテクチャパターンを参考にしつつ、やり易い方法で分割・配置する、といった手法を取っている。
また、1フォルダに置くソースコードは7ファイル以下くらいになるように心がけている。ファイルが多すぎるとファイル名の情報量が多すぎて直感的に何をするモジュールなのか個人的に分かりづらいためである。
5.テストコード
コード量やモジュールは増えれば増える程それぞれの依存関係が複雑になり、ある個所の変更が予期せぬ場所に波及したりする。そう言ったケースは多々あるので、小規模プロジェクトや方向性が全く定まっていないプロパティでない限り早めにテストコードは作成しておいた方が良い。
ただ、勿論テストケースは全網羅しようとすると非常にコスパが悪い。
よってテスト対象は絞り込み、テストケース作成の工数増加に見合うような効果が出るように心がけよう。
以下は個人的なテストケースを書く基準である。
- データ変換をする関数は、処理が余程簡単でない限りユニットテスト+結合テストをする
- 外部リソースからデータ取得する関数は、データが取れるかどうかは最低限テストする
- 外部リソースにデータを書き込む関数は、書き込み時にエラーが出ないかは最低限テストする
- UI部分は基本ユニットテストは行わず、結合テスト、E2Eテストで動作確認する
- 処理が単純で、変更も滅多に行われないような関数は、ユニットテストは作成しない(結合テストでついでに見るに留める)
- テスト結果の確認が非常に困難な関数は、処理がエラーにならず通るかを最低限確認する
特にリソースが限られているプロジェクトやチームではこういったテストの断捨離の判断が大事であるし、社内向けでそこまで品質が重要でないプロジェクトであればテスト網羅率を上げても大した効果が出ない事もある。 結局どこまでテストするかは時と場合による、というのが正しい。
とはいえ、テストケースが全く無いのと、大雑把でも網羅されているのではコードの変更時の安全性に圧倒的な差が出る。 特にチーム開発をする場合はプロジェクトの品質保証をチーム間で統一するためにも、テストコードは簡単なものでもいいので作っておこう。
ここまでほぼPythonと関係ない話になったので紹介すると、test用ライブラリは「pytest」がおすすめ。
構文がシンプルでテストケースがすぐ準備できる他、モック機能や複数のテストケースを一括実行するなど様々な便利機能を備えている。
まとめ
これらの事を実際にプロジェクトで実行したところ、Pythonを使ったにもかかわらず、非常に変更に強く、バグを事前に(実行前に)見つけて潰せる安全性の高いプロジェクトにすることが出来た。
また、コード補完によるタイピング工数の削減も地味に嬉しいポイントになった。
Pythonで本格的に開発を進めるなら、これらの事を意識してやってみるといいだろう。
勿論色々な流派があると思うので、この記事は参考程度に色々な情報を探って、
- 所属する会社に合った方法
- プロジェクトに合った方法
- 自分好みの方法
を是非見つけて欲しい。