540
479

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

DeNA 20 新卒Advent Calendar 2020

Day 17

Python3.7以上のデータ格納はdataclassを活用しよう

Last updated at Posted at 2020-12-17

はじめに

Pythonでデータを格納する際に辞書や普通のクラスを使っていませんか?Python3.7からはデータ格納に便利なdataclassデコレータが用意されています。

この記事では公式ドキュメントPEP557の説明ではいまいち掴めない、どういった時に便利で、なぜ使うべきなのかという点に触れつつ、使い方を説明していきます。

なお、以前のバージョンではPython3.6に限りpip install dataclassesによって使えるようになります。執筆時点ではGoogle Colaboratoryの環境がPython3.6.9ですが、デフォルトでdataclassesがインストールされています。

想定読者

  • dataclassの存在を知ったが何なのかよく分からない人
  • 可読性高くデータを扱いたい人
  • 「前はこんな機能なかったし、自分は別に使わなくて良いよ・・・」と思っている人

よく見かける最低限の説明

↓これが

class Person:
    def __init__(self, number, name='XXX'):
        self.number = number
        self.name = name

person1 = Person(0, 'Alice')
print(person1.number) # 0
print(person1.name) # Alice

↓こう書けます。(区別のためクラス名を明示的に変更しています)

import dataclasses
@dataclasses.dataclass
class DataclassPerson:
    number: int
    name: str = 'XXX'
        
dataclass_person1 = DataclassPerson(0, 'Alice')
print(dataclass_person1.number) # 0
print(dataclass_person1.name) # Alice

デコレータ@dataclasses.dataclassを付けて、__init__()の代わりに定義したい変数名を型アノテーション付きで書くことで使えます。

__init__()が自動的に作られたり、型アノテーションが必須になったりしている

何が変わったのかというと、まず__init__()引数をわざわざインスタンス変数に代入する必要がなくなりました。__init__()を自動的に作ってくれているということです。**変数が多い時には面倒じゃなくなるし、すっきりして嬉しいです。**また、後述しますが__eq__()__repr__()といった他の特殊メソッドも自動的に作られています。

そして、型アノテーションが必須になっているので、型が分かって嬉しいです。(ただし、これは通常のクラスでもdef __init__(self, number: int, name: str='XXX')としておきたいところ)

このクラスはデータを格納するために存在しているんだぞと明示できるというのも可読性の観点では重要な要素です。

辞書は避けたい

上記の例をやるだけであれば、辞書を使えばできます。なんでわざわざクラス、ましてdataclassデコレータなんて使うのでしょう。入出力はとりあえず辞書にしているという人は多いかと思われます。

dict_person1 = {'number': 0, 'name': 'Alice'}
print(dict_person1['number']) # 0
print(dict_person1['name']) # Alice

辞書の分かりやすいデメリットとしては、こんなところでしょうか。

  1. ドットアクセスができない。(ただし、できなくても別に良いかもしれない)
  2. 格納時の処理といったメソッドは入れられない。
  3. 型アノテーションができない。
  4. 決まった形になっていることがコードから掴みにくい。

後から読みやすい、メンテナンスしやすいコードを目指す上では3と4は大切なため、メソッドが不要な場合でも辞書を避ける理由となります。ただし、これらは通常のクラスでもカバーできます。

dataclassのメリット

dataclassデコレータを使ったクラスが通常のクラスよりどう優れているかを深堀していきます。

メリット:__eq__()が自動的に作られunittestもしやすい

インスタンスを比較した時、通常のクラスでは中身が同じでも異なるインスタンスはFalseとなります。id()が返す値を比較しているためですが、これはあまり役立ちません。unittestをするようなことを考えると、要素が一致している時はTrueになって欲しいです。

↓通常のクラスで何もしないとこうなります。

class Person:
    def __init__(self, number, name='XXX'):
        self.number = number
        self.name = name

person1 = Person(0, 'Alice')

print(person1 == Person(0, 'Alice')) # False
print(person1 == Person(1, 'Bob')) # False

↓通常のクラスで要素で比較するためには、__eq__()を自分で定義することになります。

class Person:
    def __init__(self, number, name='XXX'):
        self.number = number
        self.name = name
        
    def __eq__(self, other):
        if not isinstance(other, Person):
            return NotImplemented
        return self.number == other.number and self.name == other.name

person1 = Person(0, 'Alice')

print(person1 == Person(0, 'Alice')) # True
print(person1 == Person(1, 'Bob')) # False

↓dataclassデコレータを使えば、この__eq__()は自動的に作られます。手間が減りますし、見た目もすっきりします。

@dataclasses.dataclass
class DataclassPerson:
    number: int
    name: str = 'XXX'
        
dataclass_person1 = DataclassPerson(0, 'Alice')

print(dataclass_person1 == DataclassPerson(0, 'Alice')) # True
print(dataclass_person1 == DataclassPerson(1, 'Bob')) # False

また、@dataclasses.dataclass(order=True)とすれば、大小比較の演算のための__lt__()__le__()__gt__()__ge__()も作られます。これらはタプルを比較した時と同様に、最初に異なる要素同士で比較する仕様です。やや分かりづらいため、必要な場合は自分で定義した方が良いかもしれません。

メリット:asdictを使うとネストしていても綺麗に辞書に変換できる

JSONとして出力したいと時など、辞書に変換したい時にはdataclasses.asdict()を使います。dataclassをネストしていても問題ありません。

@dataclasses.dataclass
class DataclassScore:
    writing: int
    reading: int
    listening: int
    speaking: int
        
@dataclasses.dataclass
class DataclassPerson:
    score: DataclassScore
    number: int
    name: str = 'Alice'
        
dataclass_person1 = DataclassPerson(DataclassScore(25, 40, 30, 35), 0, 'Alice')
dict_person1 = dataclasses.asdict(dataclass_person1)
print(dict_person1) # {'score': {'writing': 25, 'reading': 40, 'listening': 30, 'speaking': 35}, 'number': 0, 'name': 'Alice'}

import json
print(json.dumps(dict_person1)) # '{"score": {"writing": 25, "reading": 40, "listening": 30, "speaking": 35}, "number": 0, "name": "Alice"}'

通常のクラスでも__dict__を使うことで辞書の形式に変換できますが、ネストしている時は一手間が必要です。

辞書からクラスに戻す時はアンパックを使い以下のようになります。

DataclassPerson(**dict_person1)

メリット:簡単にイミュータブルにできる

dataclassを使えば簡単にイミュータブルにできます。書き換えることがないデータに対してはイミュータブルにしておけば、どこかで変わっているのではないかという不安から逃れられます。

↓何も指定しないとミュータブルですが、

@dataclasses.dataclass
class DataclassPerson:
    number: int
    name: str = 'XXX'
        
dataclass_person1 = DataclassPerson(0, 'Alice')
print(dataclass_person1.number) # 0
print(dataclass_person1.name) # Alice

dataclass_person1.number = 1
print(dataclass_person1.number) # 1

↓デコレータの引数でfrozen=Trueとすると、イミュータブルになります。この時には__hash__()が自動的に作られ、hash()を使いハッシュ値を取得することもできます。

@dataclasses.dataclass(frozen=True)
class FrozenDataclassPerson:
    number: int
    name: str = 'Alice'
    
frozen_dataclass_person1 = FrozenDataclassPerson(number=0, name='Alice')
print(frozen_dataclass_person1.number) # 0
print(frozen_dataclass_person1.name) # Alice
print(hash(frozen_dataclass_person1)) # -4135290249524779415

frozen_dataclass_person1.number = 1 # FrozenInstanceError: cannot assign to field 'number'

イミュータブルにできるnamedtupleとは何が違うのか

イミュータブルにしたい用途では以下のような標準ライブラリもあります。

  • collections.namedtuple
  • typing.NamedTuple (Python3.6.1から)

これらを使うと、ドットアクセスができるタプル(=イミュータブルなオブジェクト)が作れます。

from collections import namedtuple

CollectionsNamedTuplePerson = namedtuple('CollectionsNamedTuplePerson', ('number' , 'name'))

collections_namedtuple_person1 = CollectionsNamedTuplePerson(number=0, name='Alice')
print(collections_namedtuple_person1.number) # 0
print(collections_namedtuple_person1.name) # Alice
print(collections_namedtuple_person1 == (0, 'Alice')) # True

collections_namedtuple_person1.number = 1 # AttributeError: can't set attribute

↓さらにtyping.NamedTupleは型アノテーションも可能です。

from typing import NamedTuple

class NamedTuplePerson(NamedTuple):
    number: int
    name: str = 'XXX'

namedtuple_person1 = NamedTuplePerson(0, 'Alice')
print(namedtuple_person1.number) # 0
print(namedtuple_person1.name) # Alice
print(typing_namedtuple_person1 == (0, 'Alice')) # True

namedtuple_person1.number = 1 # AttributeError: can't set attribute

詳しくはnamedtupleで美しいpythonを書く!(翻訳) - Qiitaが分かりやすいです。

dataclassとtyping.NamedTupleは似ていますが、細かい点では異なります。上記コードに載せたように、同じ要素を持つタプルとの比較でTrueになることはデメリットと言えそうです。

typing.NamedTupleの方が便利な機能としては、タプルですからアンパック代入ができることが挙げられます。使い所によっては無理にdataclassにするより良いでしょう。

各種機能

__repr__()が作られているので中身が簡単に確認できる

__repr__()が自動的に作られているので、print()などで中身が簡単に確認できます。

@dataclasses.dataclass
class DataclassPerson:
    number: int
    name: str = 'XXX'
        
dataclass_person1 = DataclassPerson(0, 'Alice')
print(dataclass_person1) # DataclassPerson(number=0, name='Alice')

通常のクラスで同じ表示をさせようとすると、以下を書く必要があります。

class Person:
    def __init__(self, number, name='XXX'):
        self.number = number
        self.name = name

    def __repr__(self):
        return f'{self.__class__.__name__}({", ".join([f"{key}={value}" for key, value in self.__dict__.items()])})' 
    
person1 = Person(0, 'Alice')
print(person1) # Person(number=0, name=Alice)

__post_init__()で初期化後の処理を書ける

通常のクラスの__init__()で代入以外の処理をしていたような時は、__post_init__()を使います。代入後にこのメソッドが呼ばれることになります。また、引数として渡さないインスタンス変数を作る場合はdataclasses.field(init=False)を使います。

@dataclasses.dataclass
class DataclassPerson:
    number: int
    name: str = 'XXX'
    is_even: bool = dataclasses.field(init=False)
    
    def __post_init__(self):
        self.is_even = self.number%2 == 0
        
dataclass_person1 = DataclassPerson(0, 'Alice')
print(dataclass_person1.number) # 0
print(dataclass_person1.name) # Alice
print(dataclass_person1.is_even) # True

InitVarで初期化用の引数を渡せる

以下の例のように、初期化時に引数として渡したいが、インスタンス変数にはしたくない値がある場合もあります。

class Person:
    def __init__(self, number, name='XXX'):
        self.name = name
        self.is_even = number%2 == 0

person1 = Person(0, 'Alice')
print(person1.name) # Alice
print(person1.is_even) # True

そういった時にはInitVarを使います。

@dataclasses.dataclass
class DataclassPerson:
    number:  dataclasses.InitVar[int]
    name: str = 'XXX'
    is_even: bool = dataclasses.field(init=False)
    
    def __post_init__(self, number):
        self.is_even = number%2 == 0
        
dataclass_person1 = DataclassPerson(0, 'Alice')
print(dataclass_person1.name) # Alice
print(dataclass_person1.is_even) # True

さいごに

入社後1年弱でのアドベントカレンダーということで、個人開発だとまあテキトウで良いかとなりがちだけど、チーム開発だと大事にしたい箇所の紹介でした。

使うと便利だけど、使わなくてもどうにかなる機能はキャッチアップを怠りがちですが、新機能には追加されるだけの理由があります。最近のPythonは型アノテーションが随分と取り入れられたりと数年前とは雰囲気もだいぶ変わりつつあります。好き嫌いはあるかもしれませんが、まずは知っておかないことには考えることもできませんので、置いてかれないようにしたいものですね!

参考文献

dataclasses --- データクラス — Python 3.9.1 ドキュメント
PEP 557 -- Data Classes | Python.org


おしらせ

この記事を読んで「面白かった」「学びがあった」と思っていただけた方、よろしければ Twitter や facebook、はてなブックマークにてコメントをお願いします!

また DeNA 公式 Twitter アカウント @DeNAxTech では、 Blog記事だけでなく色々な勉強会での登壇資料も発信してます。ぜひフォローして下さい!
Follow @DeNAxTech

540
479
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
540
479

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?