33. シンプルな構造化データのためのデータクラス
第30章では、独自の型を定義するためにクラスを作成する方法を学びました。インスタンスを初期化するための __init__ メソッド、表示するための __repr__ メソッド、比較するための __eq__ メソッドを書きました。このアプローチは完璧に機能しますが、特にクラスの主な目的がデータの保存である場合、繰り返しのコードをたくさん書く必要があります。
Python の データクラス(data class) は、主にデータのコンテナとなるクラスを、よりきれいに、より簡潔に作成する方法を提供します。@dataclass デコレータを使うと、定義したクラス属性に基づいて __init__、__repr__、__eq__ のような一般的なメソッドを Python が自動生成します。これによりボイラープレートコードが減り、意図がより明確になります。
33.1) データクラスとは何か、いつ使うべきか
データクラス(data class) とは、主にデータ値を保存するために設計されたクラスです。初期化や比較のメソッドを手書きする代わりに、クラスが持つべき属性を定義すると、必要なメソッドを Python が自動的に生成します。
データクラスが重要な理由
本を表現する通常のクラスを考えてみましょう。
class Book:
def __init__(self, title, author, year):
self.title = title
self.author = author
self.year = year
def __repr__(self):
return f"Book(title={self.title!r}, author={self.author!r}, year={self.year})"
def __eq__(self, other):
if not isinstance(other, Book):
return False
return (self.title == other.title and
self.author == other.author and
self.year == other.year)
book1 = Book("1984", "George Orwell", 1949)
print(book1) # Output: Book(title='1984', author='George Orwell', year=1949)
book2 = Book("1984", "George Orwell", 1949)
print(book1 == book2) # Output: Trueこれは動きますが、3つの情報を保存するだけのために、どれだけ多くのコードを書いたかに注目してください。__init__、__repr__、__eq__ メソッドは予測可能なパターンに従っており、定義した属性を扱っているだけです。
データクラスはこの繰り返しをなくします。特に次のような場合に便利です。
- クラスが主にデータを保存する のであって、複雑な振る舞いを実装するわけではない場合
- 初期化、文字列表現、等価比較といった 標準メソッド が必要な場合
- ボイラープレートが少なく、より明確で保守しやすい コード にしたい場合
- 設定オブジェクト、データ転送オブジェクト、シンプルなレコード を作成している場合
データクラスは通常のクラスを置き換えるものではなく、補完するものです。カスタムの初期化ロジック、複雑なメソッド、継承階層が必要な場合は通常のクラスを使います。関連するデータのための構造化されたコンテナが主に必要な場合はデータクラスを使います。
データクラスと通常のクラスの関係
データクラスは、依然として通常の Python クラスです。第30〜32章で学んだ、メソッド、プロパティ、継承、特殊メソッドといったすべての機能をサポートします。@dataclass デコレータは一般的なメソッドの作成を自動化するだけで、繰り返しコードを書く手間を省いてくれます。
33.2) @dataclass でデータクラスを作成する
データクラスを作成するには、dataclasses モジュールから dataclass デコレータをインポートし、クラス定義に適用します。クラス内では、クラスが保持すべきデータを指定するために 型アノテーション付きのクラス属性 を定義します。
基本的なデータクラスの構文
from dataclasses import dataclass
@dataclass
class Student:
name: str
student_id: int
gpa: float
# インスタンスを作成
alice = Student("Alice Johnson", 12345, 3.8)
bob = Student("Bob Smith", 12346, 3.5)
print(alice) # Output: Student(name='Alice Johnson', student_id=12345, gpa=3.8)
print(bob) # Output: Student(name='Bob Smith', student_id=12346, gpa=3.5)@dataclass デコレータが何をするのか、分解して見てみましょう。
-
@dataclass: このデコレータを適用すると、__init__、__repr__、__eq__メソッドを Python が自動的に書いてくれます -
自動
__init__: 定義順に3つのパラメータを受け取り、それらをインスタンス属性に代入する初期化メソッドを Python が作成します -
自動
__repr__: クラス名とすべての属性値を表示する文字列表現を Python が作成します -
自動
__eq__: すべての属性を比較する等価比較メソッドを Python が作成します -
型アノテーションをインスタンス属性へ変換する: 通常のクラスでは、クラス本体に
name: strと書くとクラス属性が作成されます。しかし@dataclassデコレータはこの挙動を変更し、これらの型アノテーションを使ってインスタンス属性を定義します。その結果、各インスタンスはそれぞれ固有のname、student_id、gpa属性を持ちます。
通常のクラスとの重要な違いは次のとおりです。
# 通常のクラス - これらはクラス属性(すべてのインスタンスで共有)
class RegularStudent:
name: str
student_id: int
# データクラス - これらはインスタンス属性になる(各インスタンスが独自に持つ)
@dataclass
class DataStudent:
name: str
student_id: intデータクラスにおける型アノテーションの理解
データクラスでは、型アノテーションが属性を定義し、期待される型をドキュメント化します。
from dataclasses import dataclass
@dataclass
class Product:
name: str
price: float
in_stock: bool
# ドキュメント化された正しい型を使用
laptop = Product("Laptop", 999.99, True)
print(laptop) # Output: Product(name='Laptop', price=999.99, in_stock=True)
# Python は型を強制しないため、これはエラーなしで動きます
macbook = Product("Macbook", "expensive", True)
print(macbook) # Output: Product(name='Macbook', price='expensive', in_stock=True)
# ただし、誤った型を使うと後で問題になります:
discounted = laptop.price * 0.9 # Works: 899.991
discounted = macbook.price * 0.9 # TypeError: can't multiply sequence by non-int of type 'float'
tax = laptop.price + 50 # Works: 1049.99
tax = macbook.price + 50 # TypeError: can only concatenate str (not "int") to strPython は、データクラスのインスタンス作成時に誤った型を渡しても止めません。型アノテーションは主にドキュメントであり、他のプログラマ(および mypy のような型チェックツール)に、期待する型を伝えますが、Python は実行時にそれを強制しません。これは Python の動的型付けの思想と一致しています。
ただし、型アノテーションに従うことでコードは予測可能になり、デバッグもしやすくなります。誤った型を使うと、データを使おうとした後の段階でエラーが現れるため、バグの追跡が難しくなります。型チェックツールは、コードを実行する前にこれらの不一致を検出できるため、早期に問題を見つけるのに役立ちます。
属性へのアクセスと変更
データクラスのインスタンスは、通常のクラスインスタンスとまったく同じように動作します。ドット記法で属性にアクセスし、変更します。
from dataclasses import dataclass
@dataclass
class Employee:
name: str
position: str
salary: float
emp = Employee("Sarah Chen", "Software Engineer", 95000.0)
# 属性にアクセス
print(emp.name) # Output: Sarah Chen
print(emp.position) # Output: Software Engineer
# 属性を変更
emp.salary = 100000.0
emp.position = "Senior Software Engineer"
print(emp) # Output: Employee(name='Sarah Chen', position='Senior Software Engineer', salary=100000.0)データクラスはデフォルトで ミュータブル(mutable) です。作成後に属性を変更できます。これはイミュータブルなタプルや名前付きタプルとは異なります。イミュータブル性が必要な場合は、frozen=True でデータクラスを設定できます(これについては 33.4 節で取り上げます)。
33.3) 生成されるメソッド: __init__, __repr__, __eq__
@dataclass デコレータは、3つの重要なメソッドを自動生成します。これらのメソッドが何をするかを理解すると、データクラスを効果的に使え、いつカスタマイズすべきかも分かります。
生成される __init__ メソッド
__init__ メソッドは、与えられた値で新しいインスタンスを初期化します。Python は属性定義の順序に基づいてこれを生成します。
from dataclasses import dataclass
@dataclass
class Rectangle:
width: float
height: float
# 生成された __init__ は width と height をその順で受け取ります
rect = Rectangle(10.5, 5.0)
print(rect.width) # Output: 10.5
print(rect.height) # Output: 5.0
# キーワード引数も使えます
rect2 = Rectangle(height=8.0, width=12.0)
print(rect2.width) # Output: 12.0
print(rect2.height) # Output: 8.0生成される __init__ は、次のように書くのと同等です。
def __init__(self, width: float, height: float):
self.width = width
self.height = heightこの自動生成により、特に属性が多いクラスで繰り返しの初期化コードを書く手間が省けます。
生成される __repr__ メソッド
__repr__ メソッドは、すべての属性値を表示するインスタンスの文字列表現を提供します。これはデバッグやログ出力にとても役立ちます。
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
label: str
point = Point(3.5, 7.2, "A")
print(point) # Output: Point(x=3.5, y=7.2, label='A')
print(repr(point)) # Output: Point(x=3.5, y=7.2, label='A')生成される __repr__ は、オブジェクトを再作成できそうな形式でクラス名とすべての属性を表示するという慣習に従います。__repr__ を定義しない場合に得られるデフォルト表現 <__main__.Point object at 0x...> より、はるかに有用です。
生成される __eq__ メソッド
__eq__ メソッドは、インスタンス同士の等価比較を可能にします。2つのデータクラスインスタンスは、対応するすべての属性が等しい場合に等しいとみなされます。
from dataclasses import dataclass
@dataclass
class Color:
red: int
green: int
blue: int
color1 = Color(255, 0, 0)
color2 = Color(255, 0, 0)
color3 = Color(0, 255, 0)
print(color1 == color2) # Output: True (same RGB values)
print(color1 == color3) # Output: False (different RGB values)
print(color1 is color2) # Output: False (different objects in memory)この自動の等価比較は、同一性ではなく 値の等価性(value equality) に基づきます。is が示すとおり color1 と color2 はメモリ上で別のオブジェクトですが、属性が一致しているため等しいと判断されます。
生成される __eq__ メソッドは、定義した順序で属性を比較します。
from dataclasses import dataclass
@dataclass
class Book:
title: str
author: str
year: int
book1 = Book("1984", "George Orwell", 1949)
book2 = Book("1984", "George Orwell", 1949)
book3 = Book("Animal Farm", "George Orwell", 1945)
print(book1 == book2) # Output: True (all attributes match)
print(book1 == book3) # Output: False (title and year differ)
# Book 以外のオブジェクトとの比較は False を返します
print(book1 == "1984") # Output: False
print(book1 == None) # Output: False生成されたメソッドと手動実装の比較
データクラスが提供するものを実感するために、データクラス版と手動実装を比較してみましょう。
from dataclasses import dataclass
# データクラス版(簡潔)
@dataclass
class PersonData:
first_name: str
last_name: str
age: int
# 同等の手動版(冗長)
class PersonManual:
def __init__(self, first_name: str, last_name: str, age: int):
self.first_name = first_name
self.last_name = last_name
self.age = age
def __repr__(self):
return f"PersonManual(first_name={self.first_name!r}, last_name={self.last_name!r}, age={self.age})"
def __eq__(self, other):
if not isinstance(other, PersonManual):
return False
return (self.first_name == other.first_name and
self.last_name == other.last_name and
self.age == other.age)
# どちらも同じように動作します
p1 = PersonData("Alice", "Johnson", 30)
p2 = PersonManual("Alice", "Johnson", 30)
print(p1) # Output: PersonData(first_name='Alice', last_name='Johnson', age=30)
print(p2) # Output: PersonManual(first_name='Alice', last_name='Johnson', age=30)データクラス版は、同じ機能を大幅に少ないコードで実現します。このボイラープレートの削減によって、コードは読みやすく、保守しやすく、変更もしやすくなります。
データクラスにカスタムメソッドを追加する
データクラスには、通常のクラスと同じようにカスタムメソッドを持たせられます。@dataclass デコレータが生成するのは初期化・表現・等価比較のメソッドだけで、他の機能は自由に追加できます。
from dataclasses import dataclass
@dataclass
class Temperature:
celsius: float
def to_fahrenheit(self):
"""Convert temperature to Fahrenheit."""
return (self.celsius * 9/5) + 32
def to_kelvin(self):
"""Convert temperature to Kelvin."""
return self.celsius + 273.15
def is_freezing(self):
"""Check if temperature is at or below freezing point."""
return self.celsius <= 0
temp = Temperature(25.0)
print(temp) # Output: Temperature(celsius=25.0)
print(f"{temp.celsius}°C = {temp.to_fahrenheit()}°F") # Output: 25.0°C = 77.0°F
print(f"Kelvin: {temp.to_kelvin()}") # Output: Kelvin: 298.15
print(f"Freezing: {temp.is_freezing()}") # Output: Freezing: False
cold_temp = Temperature(-5.0)
print(f"Freezing: {cold_temp.is_freezing()}") # Output: Freezing: Trueデータクラスは(初期化、表現、比較という)繰り返し部分を扱いながら、上の温度変換メソッドのように必要に応じてカスタムメソッドを追加できます。
33.4) デフォルト値とフィールドオプション
データクラスは属性のデフォルト値をサポートしており、すべての引数を指定しなくてもインスタンスを作成できます。また field() 関数を使うと、比較から除外したり、文字列表現での表示を制御したりといった高度な挙動を設定できます。
デフォルト値を指定する
クラス定義で属性に直接デフォルト値を代入できます。デフォルトを持つ属性は、デフォルトを持たない属性の後に置く必要があります。
from dataclasses import dataclass
@dataclass
class User:
username: str
email: str
is_active: bool = True # Default value
role: str = "user" # Default value
# デフォルトあり/なしでインスタンスを作成
user1 = User("alice", "alice@example.com")
print(user1) # Output: User(username='alice', email='alice@example.com', is_active=True, role='user')
user2 = User("bob", "bob@example.com", False, "admin")
print(user2) # Output: User(username='bob', email='bob@example.com', is_active=False, role='admin')
# キーワード引数で特定のデフォルトだけを上書き
user3 = User("charlie", "charlie@example.com", role="moderator")
print(user3) # Output: User(username='charlie', email='charlie@example.com', is_active=True, role='moderator')この並び順のルール(デフォルトなしの属性が先、デフォルトありの属性が後)は、生成される __init__ メソッドでの曖昧さを防ぎます。これは第20章で学んだ、デフォルト値を持つ関数パラメータに対する要件と同じです。
ミュータブルなデフォルト値と、なぜ許可されないのか
データクラスは、ミュータブルなデフォルトに関するよくあるミスから守ってくれます。リストや辞書のようなミュータブルなオブジェクトを、そのままデフォルトとして使おうとするとエラーになります。
from dataclasses import dataclass
# これはエラーを発生させます
@dataclass
class ShoppingCart:
customer: str
items: list = [] # ValueError: mutable default <class 'list'> for field items is not allowed: use default_factoryこのエラーは、第20章で見た関数のデフォルト引数と同じ問題(すべてのインスタンスが同じミュータブルオブジェクトを共有してしまう)を防ぐためのものです。
ミュータブルなデフォルトには field() と default_factory を使う
解決策は、field() 関数と default_factory を使うことです。これにより、各インスタンスごとに新しいデフォルト値が作成されます。
from dataclasses import dataclass, field
@dataclass
class ShoppingCart:
customer: str
items: list = field(default_factory=list) # Correct: New list per instance
# これで各インスタンスは独自のリストを持ちます
cart1 = ShoppingCart("Alice")
cart1.items.append("Book")
print(cart1.items) # Output: ['Book']
cart2 = ShoppingCart("Bob")
print(cart2.items) # Output: [] - Bob has an empty list
cart2.items.append("Laptop")
print(cart1.items) # Output: ['Book'] - Alice's cart unchanged
print(cart2.items) # Output: ['Laptop'] - Bob's cart independentdefault_factory パラメータは、属性が指定されずにインスタンスが作成されるたびに呼び出され、新しいデフォルト値を生成する関数(list、dict、set など)を受け取ります。例えば default_factory=list は、Python が list() を呼び出して、各インスタンス向けの新しい空リストを作成することを意味します。
比較からフィールドを除外する
等価比較から除外したい属性がある場合もあります。その場合は field(compare=False) を使います。
from dataclasses import dataclass, field
from datetime import datetime
@dataclass
class LogEntry:
message: str
level: str
timestamp: datetime = field(compare=False) # Don't compare timestamps
# 同じメッセージだが時刻が異なる2つのログエントリを作成
entry1 = LogEntry("User logged in", "INFO", datetime(2024, 1, 15, 10, 30))
entry2 = LogEntry("User logged in", "INFO", datetime(2024, 1, 15, 10, 35))
# timestamp は比較から除外されるため等しい
print(entry1 == entry2) # Output: True
# ただしタイムスタンプは異なります
print(entry1.timestamp) # Output: 2024-01-15 10:30:00
print(entry2.timestamp) # Output: 2024-01-15 10:35:00これは、2つのインスタンスが等しいとみなされるかどうかに影響させたくないメタデータフィールド(タイムスタンプ、ID、内部カウンタなど)がある場合に役立ちます。
表現からフィールドを除外する
field(repr=False) を使うと、文字列表現からフィールドを除外することもできます。
from dataclasses import dataclass, field
@dataclass
class Account:
username: str
email: str
password: str = field(repr=False) # Don't show password in repr
account = Account("alice", "alice@example.com", "secret123")
print(account) # Output: Account(username='alice', email='alice@example.com')
# Password is not shown, but it's still stored
print(account.password) # Output: secret123これは、パスワード、API キー、あるいは表現が冗長になってしまう大きなデータ構造のような機密情報に特に有用です。
frozen=True でデータクラスをイミュータブルにする
デフォルトでは、データクラスのインスタンスはミュータブルで、作成後に属性を変更できます。タプルのようにイミュータブルなインスタンスが欲しい場合は frozen=True を使います。
from dataclasses import dataclass
@dataclass(frozen=True)
class Point:
x: float
y: float
point = Point(3.0, 4.0)
print(point) # Output: Point(x=3.0, y=4.0)
# 変更を試みるとエラーになります
try:
point.x = 5.0
except AttributeError as e:
print(f"Error: {e}") # Output: Error: cannot assign to field 'x'フリーズされたデータクラスは、データの整合性を保証したい場合や、インスタンスを辞書のキーとして使いたい場合(辞書のキーはイミュータブルである必要があるため)に便利です。データクラスがフリーズされると、Python は __hash__ メソッドも生成し、インスタンスをハッシュ可能にします。
from dataclasses import dataclass
@dataclass(frozen=True)
class Coordinate:
latitude: float
longitude: float
# フリーズされたインスタンスは辞書のキーにできます
locations = {
Coordinate(40.7128, -74.0060): "New York",
Coordinate(51.5074, -0.1278): "London",
Coordinate(35.6762, 139.6503): "Tokyo"
}
nyc = Coordinate(40.7128, -74.0060)
print(locations[nyc]) # Output: New York33.5) __post_init__ によるカスタム初期化
生成された __init__ メソッドの実行後に、追加のセットアップを行いたい場合があります。__post_init__ メソッドは初期化の後に自動的に呼び出され、データの検証、派生属性の計算、その他のセットアップ作業を行えます。
基本的な __post_init__ の使い方
__post_init__ メソッドは、生成された __init__ によってすべての属性が設定された後に呼び出されます。
from dataclasses import dataclass
@dataclass
class Rectangle:
width: float
height: float
area: float = 0.0 # __post_init__ で計算されます
def __post_init__(self):
"""初期化後に面積を計算します。"""
self.area = self.width * self.height
rect = Rectangle(5.0, 3.0)
print(rect) # Output: Rectangle(width=5.0, height=3.0, area=15.0)
print(f"Area: {rect.area}") # Output: Area: 15.0__post_init__ メソッドは、初期化中に設定されたすべてのインスタンス属性へアクセスできます。これは、複数の属性に依存する派生値を計算するのに便利です。
post_init でデータを検証する
__post_init__ のよくある用途は、与えられたデータが特定の要件を満たしているか検証することです。
from dataclasses import dataclass
@dataclass
class BankAccount:
account_number: str
balance: float
def __post_init__(self):
"""口座データを検証します。"""
if self.balance < 0:
raise ValueError("Balance cannot be negative")
# 正常な口座
account1 = BankAccount("ACC001", 1000.0)
print(account1) # Output: BankAccount(account_number='ACC001', balance=1000.0)
# 異常な口座 - 残高がマイナス
try:
account2 = BankAccount("ACC002", -500.0)
except ValueError as e:
print(f"Error: {e}") # Output: Error: Balance cannot be negativeこの検証により、インスタンスが常に有効な状態であることが保証されます。データが要件を満たさなければインスタンスは作成されず、プログラム内に無効なオブジェクトが存在するのを防げます。
field(init=False) と post_init を併用する
__post_init__ で計算されるが、__init__ のパラメータにしたくない属性が欲しい場合があります。その場合は field(init=False) を使います。
from dataclasses import dataclass, field
import math
@dataclass
class Circle:
radius: float
area: float = field(init=False) # __init__ のパラメータにはしません
circumference: float = field(init=False)
def __post_init__(self):
"""radius から area と circumference を計算します。"""
self.area = math.pi * self.radius ** 2
self.circumference = 2 * math.pi * self.radius
# 初期化時に必要なのは radius だけです
circle = Circle(5.0)
print(circle) # Output: Circle(radius=5.0, area=78.53981633974483, circumference=31.41592653589793)
print(f"Area: {circle.area:.2f}") # Output: Area: 78.54
print(f"Circumference: {circle.circumference:.2f}") # Output: Circumference: 31.42このパターンは、他の属性から常に計算され、初期化時に直接設定されるべきではない属性がある場合に有用です。
データクラスは、クラスの完全な力を保ちながらボイラープレートを減らす、現代的な Python の機能です。構造化データを扱うときに、クリーンで読みやすいコードを作るうえで特に価値があります。Python の学習を続けるうちに、データ中心のプログラミングタスクの多くで、データクラスが自然な選択肢になっていくはずです。これは、第30〜32章で学んだ通常のクラスを補完するものです。