0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PEP 604のUnion syntax (X | Y) が前方参照に対応していない件

Last updated at Posted at 2025-01-26

本記事の要約

  • PEP 604で追加された | によるUnion記法はランタイムにおいて前方参照に対応していない
  • つまり、"X" | Y といった書き方をするとランタイムエラーとなる
  • これを防ぐにはいくつか方法がある
  • 端的に解決するなら "X | Y" と書くのがよさそう
  • 代替コンストラクタなどではtyping.Self を使用した解決策がよさそう

具体的には、以下のように書くと、

from dataclasses import dataclass

@dataclass(frozen=True)
class TestA:
    name: str
    age: int

    @classmethod
    def from_dict(cls, data: dict) -> 'TestA' | None:
        name = data.get('name')
        age = data.get('age')
        if name is None or age is None:
            return None
        return cls(name, age)

testa = TestA.from_dict({'name': 'tane', 'age': 10})

以下のようなエラーが出るから、

Traceback (most recent call last):
  File ".../test.py", line 4, in <module>
    class TestA:
  File ".../test.py", line 9, in TestA
    def from_dict(cls, data: dict) -> 'TestA' | None:
                                      ~~~~~~~~^~~~~~
TypeError: unsupported operand type(s) for |: 'str' and 'NoneType'

諸々考慮して、以下のように書き直すべきであるという話。

from dataclasses import dataclass
from typing import Self

@dataclass(frozen=True)
class TestA:
    name: str
    age: int

    @classmethod
    def from_dict(cls, data: dict) -> Self | None:
        name = data.get('name')
        age = data.get('age')
        if name is None or age is None:
            return None
        return cls(name, age)

testa = TestA.from_dict({'name': 'tane', 'age': 10})

ここから下は自身のメモ的なものであり、とても長くなっているので、読まなくて大丈夫です。

本記事を書くに至った経緯

筆者はPythonを用いた開発におけるクラスの実装において、代替コンストラクタを使用することが多い。

この際に、以下のような書き方をすると問題が起きる。

from dataclasses import dataclass

@dataclass(frozen=True)
class TestA:
    name: str | None = None
    age: int | None = None

    @classmethod
    def from_dict(cls, data: dict) -> TestA:
        name = data.get('name', None)
        age = data.get('age', None)
        return cls(name, age)

testa = TestA.from_dict({'name': 'tane', 'age': 10})
Traceback (most recent call last):
  File ".../test.py", line 4, in <module>
    class TestA:
  File ".../test.py", line 9, in TestA
    def from_dict(cls, data: dict) -> TestA:
                                      ^^^^^
NameError: name 'TestA' is not defined

TestAというクラスを定義するための一環として、 from_dict というメソッドを確認したところ、今まさに定義しようとしている (つまりまだ未定義の) TestA が出てきたためエラーが起きてしまうのだ。

これに対しては、様々な方法で対応できるのだが、筆者は以下のような前方参照を用いることで解決することがこれまで多かった。

from dataclasses import dataclass

@dataclass(frozen=True)
class TestA:
    name: str | None = None
    age: int | None = None

    @classmethod
    def from_dict(cls, data: dict) -> 'TestA': # クラス名をクォーテーションで囲う
        name = data.get('name', None)
        age = data.get('age', None)
        return cls(name, age)

testa = TestA.from_dict({'name': 'tane', 'age': 10})

このようにすることで、ランタイムエラーが発生することなく、実際にタイプヒントを使用する際に定義された TestA を用いることができるようになる。

しかし、上記だと、代替コンストラクタの引数が不正な値であったことを検知しづらく、デバッグが難航するかもしれない。

そこで、そもそも引数が不正な場合はNoneを返すようにし、メンバーには必ず何かしらの値が入っている状況を担保しようと考え、以下のような実装を検討する。

from dataclasses import dataclass

@dataclass(frozen=True)
class TestA:
    name: str
    age: int

    @classmethod
    def from_dict(cls, data: dict) -> 'TestA' | None:
        name = data.get('name')
        age = data.get('age')
        if name is None or age is None:
            return None
        return cls(name, age)

testa = TestA.from_dict({'name': 'tane', 'age': 10})

すると、以下のようなエラーが起きてしまう。

Traceback (most recent call last):
  File ".../test.py", line 4, in <module>
    class TestA:
  File ".../test.py", line 9, in TestA
    def from_dict(cls, data: dict) -> 'TestA' | None:
                                      ~~~~~~~~^~~~~~
TypeError: unsupported operand type(s) for |: 'str' and 'NoneType'

これが色々よく分からないなぁとなり、諸々調べ始めた。

エラーの原因について

再度、上記エラーを確認してみる。

Traceback (most recent call last):
  File ".../test.py", line 4, in <module>
    class TestA:
  File ".../test.py", line 9, in TestA
    def from_dict(cls, data: dict) -> 'TestA' | None:
                                      ~~~~~~~~^~~~~~
TypeError: unsupported operand type(s) for |: 'str' and 'NoneType'

これを見ると、以下の2点が気になった。

  • TestA が文字列と判定されていること
  • 文字列と None の Union 記法がサポートされていないということ

TestA が文字列と判定されていること

前者については当時、前方参照の存在を詳しく知らずとも、なんとなく定義されていない TestA を一時的に文字列として敢えて見過ごしているから故に発生していると分かった。

実際に、上記ドキュメントには以下のような記述がある。

When a type hint contains names that have not been defined yet, that definition may be expressed as a string literal, to be resolved later.

型ヒントにまだ定義されていない名前が含まれている場合、その定義は文字列リテラルとして表現され、後で解決される。

このために、上記エラー発生のタイミングでは TestA がまだ文字列として判別されてしまっているようだ。

文字列と None の Union 記法がサポートされていないということ

しかし、自身の中で腑に落ちなかったのは後者の問題の方である。

TypeError: unsupported operand type(s) for |: 'str' and 'NoneType' というエラー文をそのまま調べると、「 str | None という記法が許容されるようになったのはPython3.10以降だよ」といった回答ばかり見つかる。

しかし、実際に自身の環境を確認してみると、pythonのバージョンは 3.11.9 であり、当然上記記法が使える環境のはずなのだ。

実際に、 str | None といった書き方は他の箇所でも行なっており、バージョンに依存した問題とは考えられない。何か他に問題があるはずだと考えた。

で、cpythonのレポジトリのイシューを見にいったところ、似たような問題が取り上げられていた。

The class methods have a problem compiling when the type refers to the union of itself and others.

# tmp.py
class Foo:
    def __init__(self, tmp: "Foo"|int):
        pass
# Error
Traceback (most recent call last):
  File "/Project/Mslc/Grammar/tmp.py", line 1, in <module>
    class Foo:
  File "/Project/Mslc/Grammar/tmp.py", line 2, in Foo
    def __init__(self, tmp: "Foo"|int):
TypeError: unsupported operand type(s) for |: 'str' and 'type'

まさにこれでは?と思ったが、イシューはしっかり "completed" としてクローズされており、どのような回収がされたのかと確認してみた。

公式の対応

上記イシューは以下のようなコメントで締めくられていた。

We've now documented that PEP-604 aliases do not support forward references at runtime.

つまり、Pythonランタイムにおいては、 Union 記法のエイリアスは前方参照をサポートしないとのこと。これに際して、ドキュメントに変更を加えたとの記載もあっため、該当のドキュメントを見にいったところ、以下のような記載があった。

Note The | operand cannot be used at runtime to define unions where one or more members is a forward reference. For example, int | "Foo", where "Foo" is a reference to a class not yet defined, will fail at runtime. For unions which include forward references, present the whole expression as a string, e.g. "int | Foo".

つまり、似たようなことをやりたいのであれば "X" | Y ではなく "X | Y" とかけということで出会った。

では、どのように対応したか

これを踏まえて、自身はどのように対応したかを紹介する。まず、自身が実装したかったクラスの実装を振り返る。

from dataclasses import dataclass

@dataclass(frozen=True)
class TestA:
    name: str
    age: int

    @classmethod
    def from_dict(cls, data: dict) -> 'TestA' | None:
        name = data.get('name')
        age = data.get('age')
        if name is None or age is None:
            return None
        return cls(name, age)

testa = TestA.from_dict({'name': 'tane', 'age': 10})

要は、 'TestA' | None の部分が問題なのだが、複数の解決方法が見つかった。

"X | Y" という記法を使う

上でも述べた、公式がドキュメントに書いていた方法である。これは本問題において、簡潔な解決策と言えるが、後ほどのべる「クラスの継承における問題」を解決できないため、自身の場合は採用しなかった。

UnionOptional を使う

typingUnionOptional を使う方法がある。要は、

    @classmethod
    def from_dict(cls, data: dict) -> Union['TestA', None]:

や、

    @classmethod
    def from_dict(cls, data: dict) -> Optional['TestA']:

と書く方法である。ただ、エイリアスを用いた記法が公式ドキュメントでも推奨されていることからも、このような解決方法は最新バージョンの Python では好ましくないように思えた。

To define a union, use e.g. Union[int, str] or the shorthand int | str. Using that shorthand is recommended.

__future__ import annotations を使う

また、 from __future__ import annotations を使うことで前方参照を使わずに、

from __future__ import annotations
......
    @classmethod
    def from_dict(cls, data: dict) -> TestA | None:

と書くことも可能だ。

しかし、クラスの継承を視野に入れた場合、このような代替コンストラクタの返り値に対するタイプヒントはあまり好ましくない可能性が高い。例えば、以下のような場合を考える。

from __future__ import annotations
from dataclasses import dataclass
from typing import Optional

@dataclass(frozen=True)
class TestA:
    name: str
    age: int

    @classmethod
    def from_dict(cls, data: dict) -> TestA | None:
        name = data.get('name')
        age = data.get('age')
        if name is None or age is None:
            return None
        return cls(name, age)

@dataclass(frozen=True)
class TestB(TestA):
    def put_testb(self):
        print('This is TestB')

testa = TestA.from_dict({'name': 'tane', 'age': 10})
'''
<class '__main__.TestA'>
True
'''
print(type(testa))
print(isinstance(testa, TestA))

testb = TestB.from_dict({'name': 'tane', 'age': 10})
'''
<class '__main__.TestB'>
True
'''
print(type(testb))
print(isinstance(testb, TestB))
'''
This is TestB
'''
testb.put_testb()

これは問題なく実行され、実行時には確かに継承先の TestB の代替コンストラクタから生成された testbTestB と認識される。しかし、 VScode で Pylance の拡張機能を使用している場合、以下のように正しくクラスの情報を解析してくれない (バージョン: 2024.12.1 において) 。

スクリーンショット 2025-01-26 22.02.09.png

また、コード上からも一見すると、 from_dictTestB から呼び出した時に本当に TestB のインスタンスを返してくれるのかわかりづらい。

このような議論は下記の stackoverflow でも散見される。

(本命) typing.Self を使う

そこで、上記の議論も参考にした結果、自身は typing.Self を使用した書き方を用いることにした。具体的には、以下のような形だ。

from typing import Self
......
    @classmethod
    def from_dict(cls, data: dict) -> Self | None:
......

これにより、 Pylance による解析が失敗する問題も解決できる。

スクリーンショット 2025-01-26 22.11.19.png

感想

Python は同じことしようと思っても、解決方法が複数あるので、特に複数人で開発している時は大変だなーと思いました (小並感) 。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?