#はじめに
2019年10月にリリースを予定しているPython3.8で新たに加わる変更をPython3.8の新機能 (まとめ)という記事でまとめ始めました。その中で比較的分量のある項目を別記事に切り出すことにしましたが、その第二弾として代入式(Assignment Expressions)の導入を取り上げてみたいと思います。
なお、この変更は現時点でまだWhat’s New In Python 3.8には載っていないのですが、PEP-572で提案されている内容が既に開発版で実装されており、このコメントからも3.8a1に入ってきそうなので書いちゃいます。
#式と文
「『代入式の導入』って、そもそもpython には普通に代入文があるじゃん」って思われた方もいるかも知れません。式と文で何が違うのか。ググれば丁寧に解説しているサイトがすぐに見つかると思うので、ここではその違いをごく簡単に書いてみたいと思います。
とっても乱暴に言ってしまうと、
- **文(Statement)**は値を返さない
- **式(Expression)**は値を返す
となります。別の言い方をすると、式は評価した結果を変数に代入したり関数の引数に指定できますが、文はそれができない。例を挙げてみると、例えばif文やfor文の結果を他の変数に代入したりできません。代入文も同じで代入文の左辺の変数に右辺の値が割り当てられますが、代入文でそのものは値を返しません。
一方で、例えば1+2
の様な演算式やfunc(1, 2)
の様な関数呼び出しは値を返します。そしてそれらは代入文の右辺のとして使えたり他の関数の引数に与えたりできます。さらにif文の条件やfor 文の繰り返し指定(in の後の部分)に使えたりします。
少し脱線しますが、世の中には「式だけでプログラムを書こう」という流派があり、それを関数型プログラミングと呼びます。関数型プログラミングは慣れるまで少し書きにくいところはありますが、間違いを起こしにくい副作用の少ないプログラムを書くことができると言われています。そしてそれを支援するために「関数型プログラミング言語」というのもありLisp, Scheme, Clojure, Haskellなどがその代表として知られています。
そして実はPythonを使っても関数型プログラミングは可能です。例えば「aの値が0だったらx+yをzに代入、そうでなければx-yを代入」というコードを考えた場合、以下の二つの書き方ができます。
z = 0
if a == 0:
z = x + y
else:
z = x - y
z = x + y if a == 0 else x - y
同じif
とelse
を使っているのでわかりにくいかも知れませんが、前者はif文、後者は条件式です。if文は結果を返さないので、代わりにその中で条件ごとにz
に値を代入する代入文を記述しています。後者は条件に従ってx+y
あるいはx-y
を返す式の結果をz
に代入しています。
同様に繰り返し文も式を使って書くことができます。与えられた整数のリストの要素に1を加えた新たなリストを作ることを考えます。これをFor文を使った書き方と、二種類の式を使った書き方で書いてみます。
blist = []
for i in range(len(alist)):
blist.append(alist[i] + 1)
blist = [n + 1 for n in alist]
blist = list(map(lambda n: n + 1, alist))
条件分と繰り返し文の例を見てみましたが、式で書くことの何が良いのでしょうか。上で書いた「副作用の少ないコードになる」以外に、単純に「コードの量が減る」というのがあると思います。そして余計な中間変数を減らせる。例えば上記の繰り返しの例で、得られた結果の合計値を得ようとした場合、For文の場合はblistという中間変数を無くすことはできませんが、2つ目、3つ目の例だと
sum(n + 1 for n in alist)
sum(map(lambda n: n + 1, alist))
と書けます。こんな風に「得られた結果をまた次の処理に渡して」という書き方でコンパクトにわかりやすいコードを書けるのが利点でしょう。度が過ぎると逆に複雑になりすぎて何をやっているのか追いかけるのが大変になることもあるのでほどほどにですが、適度に使うと効果的な記法だと思います。
#代入式とは
ここまでで式と文の違いは明確になったと思いますが、ではなぜ代入文に加えて代入式(Assignment Expressions)を導入するのか。それは、今まで代入文が使えなかった「式の中で変数への代入」を行いたい場合があるからです。
例えば、ある文字列の中から正規表現のパターンに合致する部分文字列を取得するコードを考えてみます。普通に考えるとこう書けます。
match = re.match(pattern, data)
if match:
result = match.group(1)
else:
result = None
あるいは先ほどの条件式を使うともう少し短く書けてこうなります。
match = re.match(pattern, data)
result = match.group(1) if match else None
それでもまだmatch
という中間変数があって嫌だなと思ってこんな風にしてみると、
result = re.match(pattern, data).group(1) if re.match(pattern, data) else None
re.match()
が2回呼ばれることになり、遅くなってしまいます。できれば、この if re.match(pattern, data)
で条件分岐に使った結果を取っておいて re.match(pattern, data).group(1)
のところで使いたいなと思いますよね。そこで代入式の登場です。代入式を使うと上の例は以下のように書けます。
result = match.group(1) if (match := re.match(pattern, data)) else None
式の中でmatch
という変数を定義してそこにre.match(pattern, data)
の結果を代入しています。そして式であるのでこの操作自体が値を返すわけですが、それは代入された値そのもの、つまりmatch
に代入された値がこの式の値になり、if
の条件分岐に使われます。さらに、そのmatch
は条件がTrueであった場合にmatch.group(1)で使われています。
まさに上で書いたように条件分岐に使った結果を取っておいてそれを後から使っていますが、これを一つの式のなかでできるのがポイントです。表記方法として新たに:=
というオペレータが導入され、<変数> := <式>
の形で変数への代入を式に埋め込むことができます。この表記をnamed expression(名前付き式)と呼ぶようです。
#どんな場合に使えるのか
PEP-572に書かれている例から幾つかピックアップしてみます。
###直前の結果をif文で利用
一番簡単でしかもよくありそうなのが以下のような例です。
length = len(s)
if length > 3:
print(length)
else:
print('too short')
これはこのように書けます。
if (length := len(s)) > 3:
print(length)
else:
print('too short')
一行減るだけですが、まあ多少スッキリしますね。気をつけなきゃならないのは、
if length := len(s) > 3:
...
と括弧を忘れてしまうと、len(s)
ではなく、len(s) > 3
の結果がlength
に入ってしまうことです。
###ファイルからの連続読み込み
8192バイトずつファイルから読み込む場合、これまでだとこのように書いていました。
while True:
chunk = file.read(8192)
if not chunk:
break
print(len(chunk))
これが、代入式を使うとこのように書けます。ずいぶんとスッキリしますよね。
while (chunk := file.read(8192)):
print(len(chunk))
###計算結果を使ったリストの作成
y = f(x)
alist = [y, y**2, y**3]
というリストは
alist = [y := f(x), y**2, y**3]
と一行で書けるようになります。
###条件付きリスト内包表記
alist
の要素x
を計算した結果f(x)
がNone
ではないモノのリストを作る場合、これまではリスト内包表記を使えず、こうするしかありませんでした。
filtered_data = []
for x in alist:
y = f(x)
if y != None:
filtered_data.append(y)
あるいは無理やり使おうとするとf(x)
を1 iteration ごとに2回呼ぶことになります。
filtered_data = [f(x) for x in alist if f(x) is not None]
これが、代入式を使うとf(x)
の呼び出しは1回でリスト内包表記を使って書けるようになります
filtered_data = [y for x in alist if (y := f(x)) is not None]
#変数のスコープ
代入式では新たなスコープ(変数の有効範囲)が定義されるわけではなく、その式が含まれている現在のスコープがそのまま適用されます。ただし例外があって、リスト・セット・辞書内包表記やジェネレータ式(今後これらをまとめて「内包表記」と呼びます)の中で使われる代入式は内包表記を含む外側のスコープがその変数のスコープになります。わかりにくいですね(^^; 例を見ると理解しやすいと思うので、幾つか出してみます。
まずは以下の例。
if any((comment := line).startswith('#') for line in lines):
print("First comment:", comment)
else:
print("There are no comments")
これは文字列のリストを一つずつ走査し、その先頭が'#'
であるかどうかをチェックしています。それをジェネレータ式でany
という組み込み関数に渡しています。any
はiterable(反復可能オブジェクト)を引数に取り、その全ての要素のor
(論理和)を取る関数です。論理和なので要素の一つでもTrue
(真)であれば結果はTrue
なので、一つでもTrue
が見つかったらその時点で走査を止めてTrue
を返して終了してしまいます。なので、上記の例だと先頭が'#'
である文字列が見つかった時点で終了します。
ここで今までと違うのは走査している文字列line
を毎回comment
という変数に代入式で代入しているところで、それをany(..)
がTrueで終わった場合(つまり’#’
が先頭にある文字列が見つかった場合)にprint
関数で出力しています。一瞬、「あれ? comment
ってジェネレータ式の中で定義されている変数だからそこを出ちゃったら参照できないんじゃないの?」と思うのですが、それだと不便なので例外的にこのような使い方をできるようにスコープが一つ外まで拡張されているようです。
似たようなもので挙げられているのが次の例です。
if all((nonblank := line).strip() == '' for line in lines):
print("All lines are blank")
else:
print("First non-blank line:", nonblank)
これは一つずつ文字列を走査して空の行がどうかを返すジェネレータ式を今度はall
で受けています。all
全ての要素のand
(論理積)を取る組み込み関数で、一つでもFalse
(偽)の値が見つかったらその時点でFalse
を返して終了する。なので上のコードは空行じゃないものが見つかったら終了し、見つけたその文字列はnonblank
に格納されています。ここでもスコープはジェネレータ式の外側まで及ぶのでそれをprint
で利用できます。
#その他の変更
PEP-527では:=
の導入以外に以下の変更が提案されています。
- 新たな例外
TargetScopeError
の追加 - 評価順序の変更(辞書内包表記で
{X:Y for ...}
でこれまでYの評価を先にするルールだったがこれをXを先にする)
ただ現時点のPython3.8a0ではこの動作が確認できなかったため説明は割愛します。動くようになった時点で再度検証してみたいと思います。
#まとめ
Python 3.8で導入予定の代入式についてPEP-572の内容を元に解説してみました。代入式は:=
という新たなオペレータの導入を伴う比較的大きな変更で、例で示してきたようにこれまで王道だった書き方も多少変わってくることになります。ライブラリなどは過去互換性を取るためにそれほど急速に普及するとはないと思いますが、3.8決め打ちで動作させることができるアプリケーションなどではこれを使ってコンパクトに記述する人も出てくるでしょう。
なお、PEPはあくまでも変更提案であり、このまま実装される保証はありません。この記事で例に出したコードは3.8の開発版で一応動作確認していますが、今後アルファ版がバージョンを重ねる毎に変わっていく可能性もありますが、大きな変更があった場合にはそれに合わせてこの記事もアップデートしていきたいと思います。