qtatsuの週報

初心者ですわぁ

【Python】f-stringとフォーマットの仕組みを特殊メソッド__format__から理解する。

前書き

先日、同僚の@JunyaFff、@takapdayon(それぞれtwitterアカウント)とPythonのfstringについての勉強会をしていた時の知見と、追加で調べたことを合わせてまとめました。

f-stringは知っているけど、パターンを覚えているだけで仕組みやFormat specificationsはよくわからない、という人向けの内容です。

参考リンク

環境

バージョン
MacOS Big Sur 11.1
Python3 3.9.1

f-stringの例と用語

f-stringはfを先頭につけた文字列リテラルです。文字列リテラル中では、波括弧{}によって、変数を置換することができます。

波括弧の部分は、置換フィールド(replacement field) と呼ばれているようです(参考リンク参照)。

>>> a = '1'
>>> print(f'-{a}-')
-1-

replacement field中で、!r!sまたは!aを変数の後ろに指定することで後述の__format__()をオーバーライドして変数の値を変換します。

この!から始まる型変換を、公式ドキュメントではconversion フィールド(conversion) と呼んでいるようです。

>>> a = '1'
>>> print(f'-{a!r}-')
-'1'-

# これと同じ。
>>> print(f'-{repr(a)}-')
-'1'-

またreplacement field中では:から始まるさまざまな記号を指定できます。下の例では「値を右寄せにしてスペース5つとなるよう0で埋める」という意味になります。 この:による書式の指定は、書式指定、書式指定文字列(format_spec、Format specifications) などと呼ばれているようです。

>>> print(f'-{a:0>5}-')
-00001-

f-stringで使う用語のまとめ

  • 置換フィールド(replacement field): {}の中身。
  • conversion フィールド(conversion): !sなど。
  • 書式指定、書式指定文字列(format_spec、Format specifications): :0>5など。

最強コマンド: help('FORMATTING')

string --- 一般的な文字列操作 — Python 3.9.2 ドキュメント こちらのドキュメントは、pythonのシェルに入り、help('FORMATTING')とすればいつでも見ることができます。 conversionやformat specificationsに指定できる値は、こちらからいつでも確認できて便利です。 特に、最後の方には例文がたくさん乗っていてそのまま使えます。

フォーマットの仕組みを考える

replacement fieldのフォーマットの仕組みを、例を挙げて考えてみます。

conversionフィールドは特殊メソッド__str__などを呼ぶ.

以下のクラスを定義します。

class MyClass:
    def __str__(self):
        return ('__str__')
    def __repr__(self):
        return ('__repr__')

このクラスをインスタンス化し、str(), repr()関数の引数にインスタンスを渡すと、特殊メソッド__str__(), __repr__()が呼ばれていることがわかります。

myclass = MyClass()

str(myclass)
#  '__str__'
repr(myclass)
#  '__repr__'

conversionの!s, !rでも、全く同じことをやっています!

print(f'-{myclass!s}-')
#  -__str__-
print(f'-{myclass!r}-')
#  -__repr__-

f-stringの置換フィールド中では、__format__()メソッドが呼ばれる。

先程のクラスのインスタンスですが、conversionを指定せずに呼ぶと以下のように、__str__()が呼ばれます この挙動がなぜ起こるのか、正確に考えてみます。

print(f'-{myclass}-')
#  -__str__-

まず、新しく__format__メソッドを導入したクラスを定義します。

class MyClassModFormatting:
    def __str__(self):
        return ('__str__')
    def __repr__(self):
        return ('__repr__')
    def __format__(self, format_spec):
        return ('__format__')

先ほどと同様にインスタンス化し、conversionをやってみます。 すると、f-stingの置換フィールドにインスタンスをおくと、__format__が呼ばれていることがわかります。

>>> myclass_mod = MyClassModFormatting()

>>> print(f'-{myclass_mod}-')
-__format__-

実は、f-sting中の置換フィールドでは、変数に対して組み込み関数format()を呼んでいるだけなのです。 そして組み込み関数format()は、対象のクラスの__format__()に全ての処理を丸投げする関数です。

format関数の定義は以下のようなものです。 PEP 3101 に定義が乗っています

def format(value, format_spec):
    return value.__format__(format_spec)

書式指定文字列(format_spec)の解釈は対象のクラス次第である。

format()関数、__fomrat__()メソッドは第二引数に書式指定文字列(format_spec)をとります。f-stringでは:から始まる記号です。 この書式指定文字列の解釈はクラスの__format__()の定義に完全に依存しています。換言すると、同じformat_specを指定しても、解釈は対象のクラスによって異なります。

以下のクラスは、与えられたformat_specをアットマークつきで返します。

class MyClassPritingSpec:
    def __format__(self, format_spec):
        return '__format__' + '@' + str(format_spec)

以下のようになります。f-stringのformat_spec(:以降の部分)をパースするのは、フォーマットされるクラスに定義された特殊メソッド__fomrat__()だということがわかります。

spec_printer = MyClassPritingSpec()
print(f'-{spec_printer:hoeeee}-')
#  -__format__@hoeeee-

datetime.datetimeオブジェクトは特殊な書式指定文字列を持つ

datetime.datetimeはstrftimeによってフォーマットされますが、これもformattingの仕組みを使っています。

以下の結果は全く同じです。dateimeクラスに定義された__format__()が、日付型専用の書式指定文字列をパースできるように定義されているのです。

>>> import datetime
>>> d = datetime.datetime(year=2000, month=3, day=3)

>>> d.strftime('%Y-%m')
'2000-03'

>>> f'{d:%Y-%m}'
'2000-03'

デフォルトの__format__()はobjectに定義されている

ここで最初の疑問に戻ります。__format__()を定義していないクラスMyClassのインスタンスは、f-stringにかけて出力すると以下のように__str__()が呼ばれるのでした。

print(f'-{myclass}-')
#  -__str__-

これは、自作クラスが継承しているobjectに定義された__format__()メソッドの挙動です。

PEP 3101 PEPとしてはここに記載されています。 引用すると、以下のようになっています。

class object:
    def __format__(self, format_spec):
        return format(str(self), format_spec)

引用終わり。

オブジェクトに対して、str()を呼んでからformatを呼んでいることがわかります。 ですので、先の例では-__str__-と出力されていたのでした。

再確認: conversion(!r)は__format__をオーバーライドする

最初の説明で、!r!sなどのconversionは「formatをオーバーライドする」と述べました。その実例をみていきます。

先程のMyClassModFormattingを再び使います。インスタンス化したあと、conversionを行います。

print(f'-{myclass_mod!s}-')
#  -__str__-
print(f'-{myclass_mod!r}-')
#  -__repr__-

結果、このクラスは自分で定義した__format__()が呼ばれていないということがわかります。__format__()の処理が、__str__()__repr__()で上書きされたのです。

ただし、!sや!rで変換した後のstringに対してはformatが呼ばれています。 書式指定文字列を渡さない場合、strはそのまま出力されるのでわかりにくいため、以下のようにしてみます。

>>> print(f'-{myclass_mod!s:0>30}-')
# -00000000000000000000000__str__-

このように、文字列に変換されたあとは「 str型に定義されている__format__ 」が呼ばれていました。

まとめ

f-stringはpython3.6から導入された便利な構文です。ただし、その処理の実体はPEP3101に記載があるformatの仕組みがそのまま使われています。(pythonのバージョンとしては3.0〜。これ以前は%によるフォーマットと、テンプレートシステムだけが存在していたようです。)

formatの仕組みは一見複雑に見えますが、基本的には3つの特殊メソッド__str__, __repr__, __format__で成り立っています。また、intかstrのformatに使う書式指定文字列を覚えていればよく、その例文はhelp('FORMATTING')でいつでもサッと確認できます。

科学計算の数値表現はもちろん、Web開発での文字列の組み立て、ログへの出力(!rを用いて型情報付きで出力するのが便利!)などなど、あらゆる場面で利用できます。仕組みを理解して上手に使っていきたいなと思います。