データサイエンティストがGoを使った開発を経験することで学んだ、Python開発の改善点
Photo by Patrick Baum on Unsplash
こんにちは、ウォンテッドリーでデータサイエンティストをしている林(@python_walker)です。この記事はWantedly Advent Calendar 2024の4日目の記事です。私は普段は推薦システムのモデルの改善に携わっていることが多く、そのため業務ではPythonを触っていることが多いのですが、最近推薦基盤を触る機会ができ、不慣れながらGoの開発を行っていた時期がありました。
自分が慣れていない言語で開発を進めるのはなかなか大変でしたが、データサイエンティストが開発したモデルがどのような基盤を通してユーザーに提供されているのか解像度を上げることができたので有意義だったと思っています。さて、GoとPythonは言語的にも大きく異なるものではあるのはそうですが、今回は扱っている領域も異なっていたので、Go製の推薦基盤システムをいじる中でPythonでの開発時に参考になりそうだと感じたことも数多くありました。この記事では、Goの開発を通して学んだことをPythonでの開発でどう活かせそうだと感じたか紹介したいと思います。自分はGoの初心者でもありますが、システム設計に関してもそこまで詳しくないので内容は非常に初歩的なものになっていると思いますが、最後まで読んでもらえたら嬉しいです。
目次
Goでの一般的な設計
Pythonで同様の設計を実現する
利点
欠点
まとめ
References
Goでの一般的な設計
Goでコードを書く時にはインターフェースが頻繁に出てきます。「インターフェースを受け取り、構造体を返す実装を書け」という言葉をGoについて調べているとよく目にしますが、これがGoに適した保守性の高いコードの書き方なのだと理解しています。
Pythonを普段使っているとインターフェースを明示的に意識することが少ないので、概念そのものの理解もすんなりとはいかないのですが、私が一番苦労したのは、構造体がどのインターフェースを実装しているのか明示的に書かないことでした。例えば、下のコードのように somethingImpl
が Something
を実装するとき、somethingImpl
は Something
を実装していることを明示的に宣言しません。
type Something interface {
MethodA() int
MethodB() (bool, error)
}
type somethingImpl struct {
attr1 int
attr2 int
}
func (*s somethingImpl) MethodA() int {
// ...
}
func (*s somethingImpl) MethodB() (bool, error) {
// ...
}
これは “Structural Subtyping” と呼ばれており、Go特有のものではないようなのですが、コードを読んでいる際にはよく混乱しました。しかし、こうした”暗黙的な”実装をすることによって、インターフェースと構造体のデカップリングを実現し、コードの変更容易性を高めることにつながっています。
Pythonで同様の設計を実現する
私はこれを見ていて、同じようなことがPythonでもできたらコードの変更容易性を上げられるのではないかと感じました。Pythonではsubtypingにクラスの継承などを使うことが一般的で、インターフェースのようなものはないということが言われますが、似たようなものであれば実は標準ライブラリの中に用意されています。それが Protocol です。これはtypingライブラリ内に用意されているもので、Goのインターフェースのように暗黙的な実装を使うことができます。
# インターフェースのようなもの
class Runnable(Protocol):
def run(self): ...
# 実装
class Processor:
def __init__(self, ...):
# ...
def run(self):
# ...
def run_process(x: Runnable):
x.run()
# ...
if __name__ == "__main__":
processor = Processor()
run_process(processor)
Processor
は Runnable
を実装していますが、それを明示的には宣言していません。
利点
次に、Protocolを使うことでどのようなメリットがあるか考えてみます。Protocolを使うことで得られるメリットとして大きいのは、やはりコンポーネント間の結合を弱くすることができることです。これに関して、データサイエンティストらしく機械学習モデルのパイプラインを例にその利点を考えてみます。機械学習モデルをプロダクトで使う際には、多くの場合モデルの推論結果に対してビジネスロジックなどに関連した後処理が加えられます。
class Postprocess(Protocol):
# ...
class Postprocess1:
# ...
class Postprocess2:
# ...
def main():
postprocesses: list[Postprocess] = [PostProcess1(), Postprocess2(), ...]
for p in postprocesses:
p.run()
# ...
加える後処理は複数あることが多いですし、時間の経過とともに増減の機会があるものです。こういったときにProtocolでインターフェースを定義しておくことで、将来的に変更しやすいコードベースになるのではないかと思います。
また、さらにコードが複雑になっていった時に、明示的に親子関係を定義するような関係(継承など)では、変更のたびにsubtypingの構造が適切か考えなくてはならなくなったり、多重継承の構造を作らなくてはいけなくなったりします。しかしProtocolでは実装との間には暗黙的な関係があるだけなので、関係性が複雑になりすぎないという利点があります。
欠点
一方で、Pythonは動的型付け言語であるので、それによる欠点というのもあると思っています。明示的に関係を記述するわけではないので、コードにミスがあって一部の実装が欠けているということがあっても、(使う側がそのメソッドをたまたま使わない関数だった場合などで)実行時エラーにならないことがあります。もちろんmypyでチェックすればエラーには気づけますが、実はちゃんと実装できていなくても実行できてしまうケースがあるというのは欠点の一つではないかと思います。
# mypyでチェックすればエラーがあることは発見できる
main.py:26: error: Incompatible types in assignment (expression has type "ConcreteClass", variable has type "ProtocolClass") [assignment]
main.py:26: note: "ConcreteClass" is missing following "ProtocolClass" protocol member:
main.py:26: note: required_method2
この場合、抽象クラスではこのようなミスは発生しづらいです。というのも、抽象化クラスでは実装する必要があるメソッドをデコレーターで書き、実装する側は明示的に関係を記述しますので、実行時にも未実装部分はエラーになります。
class SomeClass(metaclass=ABCMeta):
@abstractmethod
def methodA(self, var: int) -> int: ...
class ConcreteClass(SomeClass):
pass
# → TypeError: Can't instantiate abstract class ConcreteClass without an implementation for abstract method 'methodA'
まとめ
普段Pythonを使っているデータサイエンティストがGoを使ってみて、改めてPythonのコードの書き方を考えてみたということをつらつらと書いてみました。どんなに使い慣れた言語でも、もしかしたら使い慣れた言語だからこそかもしれませんが、まだ自分が知らない側面というものはたくさんあると思います。他の言語に触れることで、今までとは違った方向からの視点が得られ多くの学びが得られるということを今回強く実感しました。
References
- Patrick Viaforce 著、鈴木駿 監訳、長尾高弘 訳:ロバストPython、オライリージャパン (2023)
- Jon Bodner 著、武舎広幸 訳:初めてのGo言語、オライリージャパン (2022)