はじめに
2021年10月にリリースが予定されているPython3.10で新たに加わる変更をPython3.10の新機能 (まとめ)という記事でまとめています。その中で比較的分量のある項目を別記事に切り出すことにしていますが、これはその第二弾で、PEP-634 で提案されている「構造的パターンマッチング」についてです。
なお、この機能については、既に@tk0miyaさんがPython3 に提案されたばかりのパターンマッチング構文を調べてみたという記事を書かれていてバッチリ網羅的に解説されているので、ここでは Python 3.10のWhat's Newに挙げられた例を使って、私が気になったポイントを書いてみたいと思います。
この変更で解決しようとしている問題
Pythonを使い始めた人が最初に疑問に思うことの一つに「なぜswitch文がないのだろうか?」があります。C言語などでもお馴染みの機能で、ある変数の値によって3つ以上に処理の流れが分かれる場合に便利な機能です。Pythonではこれまで頑なにswitch文の導入を回避してきていました。if ... elif ... else 文で書けるだろう、ということと、辞書型を使えば3つ以上のディスパッチも簡単にできる、というのが理由でした(後者に関しては後ほど例を示します)。まあでも、if文で書けるとは言え、elif ...elif ...elif と何度も場合分けを書くのはちょっと見通しが良くないですし、辞書型を使うのも読むのに少し慣れが必要になります。
それから、より構造的な特徴によって処理を振り分けたいという場合もあります。例えば、タプルが与えられて、その要素の数によって処理を分けたい場合や、オブジェクトの型(クラス)によって処理を分けたい場合など。このような場合には、これまで len()
で要素数をまず取り出してそれによって if文で切り分けていくとか、isinstance()
を使ってどのクラスなのかをif文の中でチェックしていくなどしなければなりませんでした。これは書くのも大変ですし、さらに読む方にとっても見通しが悪いという問題がありました。
このような問題に対して、RustやScalaのような言語ではパターンマッチングの記法を導入して解決しています。switch文と似たような記法ですが、単に値のマッチングだけではなく構造的なマッチングも行うことのできる記法で、これが 3.10でPythonに取り入れられることになります。
match文の利用例
パターンマッチングの機能はmatch文によって実現されますが、幾つか使い方があります。
リテラルマッチング
これは switch文的な使い方ですね。
def http_error(status):
match status:
case 400:
return "Bad request"
case 404:
return "Not found"
case 418:
return "I'm a teapot"
case _:
return "Something's wrong with the Internet"
これはstatus
の値を見てそれに対応するエラーメッセージを返すという例です。パッと見で何をしているかはすぐにわかるかと思います。最後の _
は何にでもマッチするワイルドカードのようなもので、これでデフォルト動作を定義しています。
実は、これだけであれば、これまでも
def http_error(status):
error_string = {
400: "Bad request",
404: "Not found",
418: "I'm a teapot",
}
return error_string.get(status, "Something's wrong with the Internet")
という形で書くことができました。辞書型でステータスコードとエラーメッセージの対応表を作っておいて、それをdict.get()
で取り出す、値がない場合はデフォールト値を返す、というコードです。これが上記で述べた辞書型を使ったやり方ですが、行数は少ないものの、慣れないと何をやっているか直感的にはわかりにくいかなと思います。
なお、case
のマッチする値は複数指定できて、例えば case 401 | 403 | 404:
というように|
かor
をつかって連結できます。
リテラルと変数の組み合わせパターンにマッチング
もう少し複雑な例を考えてみます。点を(x,y)
のタプルで表すとして、それによって今どこにいるのかを文字列で返す関数は以下のように書けます。
def where_am_i(point):
match point:
case (0, 0):
return "Origin"
case (0, y):
return f"Y={y}"
case (x, 0):
return f"X={x}"
case (x, y):
return f"X={x}, Y={y}"
case _:
raise ValueError("Not a point")
ここのパターンをみると、リテラル(0
)と変数(x
、y
)が組み合わせられています。match文は上からチェックしていくので、この例だと、まずはじめに (0,0)
であるかをチェックされ、マッチすれば "Origin"
を返し、次にx
の値が0
のモノ、y
が0
のモノとチェックされ、case (x,y)
に到達する時点ではx
もy
も0
ではないものがマッチすることになります。
この例を今までのPythonで書くと例えばこのようになります。
def where_am_i(point):
try:
x,y = point
except ValueError:
raise ValueError("Not a point")
if x == 0 and y == 0:
return "Origin"
elif x == 0:
return f"Y={y}"
elif y == 0:
return f"X={x}"
else:
return f"X={x}, Y={y}"
行数的には同じくらいですが、わかりやすさと例外を出す場合も含めて統一的に書ける点でmatch文は良いですね。
クラスでのマッチング
前の例では点を表すのにタプルを使っていましたが、クラスを使うこともできます。
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
def where_am_i(point: Point):
match point:
case Point(x=0, y=0):
return "Origin"
case Point(x=0, y=yy):
return f"Y={yy}"
case Point(x=xx, y=0):
return f"X={xx}"
case Point(x=xx, y=yy):
return f"X={xx}, Y={yy}"
case _:
raise ValueError("Not a point")
ここでは点を表すのにPoint
をデータクラスとして定義していて、それでマッチングを行っています。クラスのコンストラクタに値を与えるような形で Point(x=0, y=0)
と書くとx
もy
も0
のPoint
クラスとマッチし、"Origin"
という文字列を返しています。
面白いのは変数も指定できることで、ここでは混乱しないようにxx
とyy
としていますが、例えば case Point(x=0, y=yy)
だと、「x
は0
、y
は何でも良いのでそれをyy
とする」と解釈されます。そしてcase文の本体でその変数 yy
を使えるということですね。ちょっと直感的にわかりにくいかも知れません。
クラスコンストラクタの引数の形で変数を与える代わりに、マッチしたオブジェクトそのものを使うやり方もあります。
def where_am_i(point: Point):
match point:
case Point(x=0, y=0):
return "Origin"
case Point(x=0) as p:
return f"Y={p.y}"
case Point(y=0) as p:
return f"X={p.x}"
case Point() as p:
return f"X={p.x}, Y={p.y}"
case _:
raise ValueError("Not a point")
as
を使ってマッチしたオブジェクトを変数p
に入れ、それをcase分本体で使っています。この例の場合は、変数point
を使っても同じことができますが、match文の対象が変数ではなく式だったりする場合にこのas
を使ったやり方は便利かと思います。個人的にはこちらの方が記法としてはわかりやすいですね。
そして、だんだん難しくなってきますが、これも既存のPythonで書いてみたいと思います。
def where_am_i(point: Point):
if not isinstance(point, Point):
raise ValueError("Not a point")
if point.x == 0 and point.y == 0:
return "Origin"
elif point.x == 0:
return f"Y={point.y}"
elif point.y == 0:
return f"X={point.x}"
else:
return f"X={point.x}, Y={point.y}"
こちらも例外ケースの処理を別にしなければならないのと、if ... elif ... else で値比較をしていかなければならないのでちょっと煩雑ですね。
複数クラスのマッチング
上記では一つのクラスだけの例を示しましたが、match文の中に複数のクラスのパターンが書かれていても問題なく動作します。例えばこんな例。
@dataclass
class Point:
x: int
y: int
@dataclass
class Rectangle:
width: float
length: float
@dataclass
class Circle:
radius: float
def identify_shape(shape):
match shape:
case Point():
return f"This is a Point({shape.x}, {shape.y})"
case Rectangle():
return f"This is a Rectangle({shape.width}, {shape.length})"
case Circle():
return f"This is a Circle({shape.radius})"
case _:
return "Unknown shape"
与えられたshapeがどのクラスかによって返すメッセージが変わります。これまでのPythonでも例えば
def identify_shape(shape):
if isinstance(shape, Point):
return f"This is a Point({shape.x}, {shape.y})"
elif isinstance(shape, Rectangle):
return f"This is a Rectangle({shape.width}, {shape.length})"
elif isinstance(shape, Circle):
return f"This is a Circle({shape.radius})"
else:
return "Unknown shape"
とすれば同じことができますが、match文の方がスッキリ書けると思います。
まとめ
Python 3.10で導入される構造的パターンマッチングの機能を、これまでの実装方法と比較しながら見てみました。これからどんどん使われる様になるのではないかと思います。
なお、Rustなどではマッチする値の完全性をコンパイラがチェックしてくれるので、パターンの書き忘れや、enumのバリエーションが増えた時の修正漏れなどがないのですが、Pythonのmatch文はそこまで親切(おせっかい)ではない点は注意しておいた方が良いかも知れません。
また、matchが文であって式じゃないので結果を変数で受けられないのがちょっと残念なところですが、Python的にはその方が自然ということですかね。いずれ、for文に対するリスト内包表現のように、matchの結果を返す式が書けるようになると良いなと期待しています。