クラスをいくつか書けるようになったころ、こんな場面に出会いませんか。似たようなクラスが増えてきて、どれにも同じ名前のメソッドを用意したくなる場面です。
たとえば通知の処理を考えてみてください。メールで送るクラス、Slackへ送るクラス、ログに書くだけのクラス。どれにもsend()があるはずだ、という前提でプログラムを組み立てていきます。
ところが人が増えると、その前提はあっさり崩れます。新しく追加されたクラスにsend()が無くても、Pythonは何も言わずに読み込んでくれるからです。
気づくのは、そのクラスが実際に呼ばれた瞬間です。しかも運が悪いと、本番でお客さまが操作した瞬間だったりします。
この事故を、書いた時点に近いところまで引き寄せてくれるのが抽象クラスという仕組みです。この記事では、標準ライブラリのabcを使った書き方と、初心者の方がつまずきやすい点を順番に見ていきます。
実装のし忘れは、なぜ気づきにくいのか¶
まず、何が困るのかをはっきりさせておきます。ここが腑に落ちると、抽象クラスの出番が自然に見えてきます。
Pythonには、あるメソッドを必ず持たせるという文法上の強制がありません。オブジェクトが必要なメソッドを持っていれば動く、というダックタイピングの考え方が土台にあるためです。
この柔軟さは普段とてもありがたいのですが、裏返すとメソッドの不足はプログラムが実際にその行へたどり着くまで発覚しないということになります。
しかも出るエラーはAttributeErrorです。属性が無いという知らせだけが届き、どういう約束を破ったのかまでは教えてくれません。
クラスの設計そのものが曖昧な方は、オブジェクト指向の土台から眺めておくと理解が早くなります。【関連記事】継承・カプセル化・ポリモーフィズム。オブジェクト指向の3大要素をPythonで理解
abcモジュールで、約束を守らせる¶
言葉を重ねるより、動かしたほうが早いはずです。標準ライブラリのabcからABCとabstractmethodを持ってきます。
ABCを継承したクラスの中で、実装を求めたいメソッドに@abstractmethodを付けるだけです。追加インストールは要りません。
from abc import ABC, abstractmethod
class Notifier(ABC):
@abstractmethod
def send(self, message):
...
class SlackNotifier(Notifier):
pass # send を書き忘れている
class MailNotifier(Notifier):
def send(self, message):
return f"mail: {message}"
print(MailNotifier().send("hello")) # mail: hello
SlackNotifier() # ここで TypeError
手元のPython 3.13で動かすと、最後の行でCan't instantiate abstract class SlackNotifier without an implementation for abstract method 'send'というTypeErrorが上がりました。
送るより前の、インスタンスを作る時点で止まってくれます。しかもメッセージが、どのクラスのどのメソッドが足りないのかまで名指ししてくれます。
ちなみにこのメッセージはバージョンで文言が変わります。Python 3.11で同じコードを試すとwith abstract method sendという短い形でした。
ABCの継承を忘れると、何も起きない¶
ここで、初心者の方がまず一度は踏む落とし穴を紹介します。@abstractmethodだけを付けて、ABCの継承を書き忘れるパターンです。
from abc import abstractmethod
class Plain: # ABC を継承していない
@abstractmethod
def send(self, message):
...
class Child(Plain):
pass
Child() # エラーにならない
実際に動かすと、Child()はあっさり成功します。エラーも警告も出ません。
チェックの正体は@abstractmethodではなく、ABCが持つメタクラスのほうにあるからです。@abstractmethodは、ここが抽象ですという目印を付けているだけにすぎません。
@abstractmethodは、ABCを継承したクラスの中で書いてはじめて効くと覚えておいてください。ここを外すと、守ってくれているつもりの空振りが生まれます。
エラーが出るのは、インスタンスを作るとき¶
もうひとつ、押さえておきたい性質があります。抽象クラスのチェックが働くのは、クラスを定義した瞬間ではありません。
SlackNotifierというクラスの定義そのものは、何ごともなく通ります。止まるのは、そのクラスからインスタンスを作ろうとしたときです。
つまり、まだ一度も生成していないクラスの実装漏れは見つかりません。ここは型チェッカーの担当になります。書く時点で気づきたいなら、静的な型チェックを併せて使うのが近道です。【関連記事】Pythonのmypyとは?型ヒントを本物のチェックに変える使い方を初心者向けに解説
共通の処理は、抽象クラスに書いてよい¶
抽象クラスというと、中身が空のメソッドだけを並べる入れ物だと思われがちです。実はそうではありません。
普通のメソッドを一緒に書けます。全員に共通する流れを親に置き、変わる部分だけを子どもに任せる形が作れます。
from abc import ABC, abstractmethod
class Report(ABC):
def run(self): # 共通の流れ
return f"[{self.title()}] {self.body()}"
@abstractmethod
def title(self):
...
@abstractmethod
def body(self):
...
class Daily(Report):
def title(self):
return "日報"
def body(self):
return "本日の作業"
print(Daily().run()) # [日報] 本日の作業
手元で動かすと、[日報] 本日の作業と出ました。run()は親に1つあるだけで、子どもは埋めるべき穴だけを埋めています。
report の種類が10個に増えても、組み立ての順番を書いた行は1箇所のままです。順番を変えたくなったとき、直す場所も1箇所で済みます。
プロパティやクラスメソッドも抽象にできる¶
抽象にできるのは、普通のメソッドだけではありません。@propertyや@classmethodと組み合わせられます。
順番にだけ気をつけてください。@abstractmethodは、いちばん内側に置きます。
from abc import ABC, abstractmethod
class Base(ABC):
@property
@abstractmethod
def name(self):
...
この形で、子どもがnameを用意し忘れると、やはりインスタンスを作る時点でTypeErrorになります。手元でも同じメッセージを確認しました。
なお、古い記事で見かけるabc.abstractpropertyのような専用のデコレータは、いまは非推奨です。上のように重ねて書く形が現在の書き方になります。
@propertyそのものに自信がない方は、こちらもあわせてどうぞ。【関連記事】Pythonの@propertyとは?メソッドを属性の顔で使わせる書き方を初心者向けに解説
collections.abcという、身近な実例¶
抽象クラスは、特別な現場だけの道具ではありません。Pythonの標準ライブラリ自身が、たっぷり使っています。
その代表がcollections.abcです。リストや辞書といったおなじみの型が、どの約束を満たしているのかを表しています。
from collections.abc import Sequence, Iterable
print(isinstance([1, 2], Sequence)) # True
print(isinstance("abc", Sequence)) # True
print(isinstance({1: 2}, Sequence)) # False
print(isinstance({1, 2}, Iterable)) # True
手元で動かすと、この順番どおりの結果になりました。文字列も順番のあるデータとして扱われている点が、少し面白いところです。
引数がリストかどうかをisinstance(x, list)で調べている箇所は、Sequenceで見たほうが柔軟になります。タプルや自作のクラスも受け入れられるようになるからです。
こうした約束が特殊メソッドと結びついている話は、別の記事で詳しく扱っています。【関連記事】Pythonの特殊メソッドとは?どんな種類があるのか?どうやって使うのか?
registerで、あとから仲間に加える¶
継承の関係が作れない相手も現実にはあります。他人が書いたライブラリのクラスなどが、その典型です。
そんなときのために、register()という抜け道が用意されています。あとから、このクラスは仲間ですと宣言する仕組みです。
ただし、ここには注意が要ります。手元で試したところ、send()を1つも持たないクラスを登録してもisinstance()はTrueを返しました。
つまりregister()は名札を貼るだけで、中身の検査はしません。便利ではありますが、実装の強制という目的からは外れる使い方になります。
抽象クラスとProtocolの違い¶
型ヒントの世界には、似た役割のProtocolがあります。どちらを使うか迷う方も多いので、違いを表で並べておきます。
| 項目 | 抽象クラス(abc) | typingのProtocol |
|---|---|---|
| 継承 | 必要(明示的に継承する) | 不要(形が合えばよい) |
| 実装漏れに気づく時点 | インスタンスを作るとき | 型チェッカーを走らせたとき |
| 実行時の効果 | TypeErrorで止まる | 既定では何も起きない |
| 共通処理の共有 | 得意(親に書ける) | 不得意(形の宣言が主) |
| 向いている場面 | 自分たちで書くクラス群 | 他人が書いたクラスも受けたいとき |
自分たちのコードで、確実に守らせたい約束があるなら抽象クラスです。外から渡ってくるオブジェクトの形だけを表したいならProtocolが向いています。
Protocolに@runtime_checkableを付ければisinstance()でも判定できます。手元で試すと、send()を持つクラスだけがTrueになりました。
実務で気をつけていること¶
私は10年ほどエンジニアとして開発に関わってきましたが、抽象クラスは効きどころを外すと重くなるだけの道具だと感じています。増やすほど良いものではありません。
いちばん痛かったのは、決済まわりの処理でメソッドを7つも抽象にした抽象クラスです。新しい決済手段を1つ足すたびに、使わないメソッドまで空で埋める作業が発生しました。
半年後には、passとだけ書かれたメソッドがずらりと並んでいました。約束を守らせるはずの仕組みが、意味のない行を量産する装置になっていたわけです。
それ以来、抽象にするのは実装が本当に分かれる部分だけと決めています。目安として、抽象メソッドが3つを超えたら分け方を疑うようにしています。
もうひとつ、種類が2つしかないうちは抽象クラスを作りません。3つめが出てきて、共通点がはっきり見えてから作るほうが、たいてい形が素直になります。
まとめ¶
抽象クラスは、必要なメソッドの実装を子どもに強制する仕組みです。abcのABCを継承し、@abstractmethodを付けたメソッドを置くだけで使えます。
実装が足りないと、インスタンスを作る時点でTypeErrorになります。呼ばれた瞬間まで隠れていた事故が、ずっと手前に出てくるのが利点です。
気をつけたいのはABCの継承を忘れると何も起きない点と、register()が中身を検査しない点です。共通の流れは親に書けるので、空のメソッドだけを並べる必要もありません。
そして、抽象メソッドは増やしすぎない。守らせる約束は少ないほど、あとから守りやすくなります。
まずはお手元のコードで、同じ名前のメソッドを持つクラスが3つ以上ないか探すところから始めてみてはいかがでしょうか。ここまでお読みいただきありがとうございました。