Python `attrs` ライブラリ徹底解説:ボイラープレートコードからの解放

はじめに: `attrs` とは何か?なぜ便利なのか?

Python でクラスを定義する際、__init__, __repr__, __eq__ といった特殊メソッド(ダンダーメソッド)を実装するのは、しばしば定型的で退屈な作業になりがちです。特に、データ保持を主目的とするクラスでは、これらのメソッドの実装はほとんど同じパターンになります。

ここで登場するのが attrs ライブラリです! attrs は、クラス定義におけるこのようなボイラープレートコード(定型的なコード)を劇的に削減し、より簡潔で、読みやすく、保守しやすいコードを書くための強力なツールを提供します。

attrs は、Hynek Schlawack氏によって2015年に開発が開始され、Pythonコミュニティで広く受け入れられています。その影響は大きく、Python 3.7で標準ライブラリとして導入された dataclasses モジュールは、attrs に強くインスパイアされています。

attrs を使う主なメリットは以下の通りです:

  • コード量の削減: __init__, __repr__, __eq__ などの自動生成により、記述量が大幅に減ります。
  • 可読性の向上: クラスの属性定義に集中でき、本質的でないコードが減るため、クラスの意図が明確になります。
  • 堅牢性の向上: バリデータやコンバータといった機能により、不正なデータを持つインスタンスの生成を防ぎやすくなります。
  • 豊富な機能: 標準の dataclasses よりも多くの高度な機能(バリデータ、コンバータ、slots の簡単な利用など)を提供します。

このブログ記事では、attrs の基本的な使い方から、便利な機能、そして dataclasses との比較まで、詳細に解説していきます。さあ、attrs の世界を探検しましょう!

基本的な使い方

まずは attrs の基本的な使い方を見ていきましょう。

attrs は標準ライブラリではないため、pip を使ってインストールする必要があります。

$ pip install attrs

注意: パッケージ名は attrs ですが、インポートする際は import attrs が付かない点に注意してください。これは歴史的な経緯によるものです。

attrs を使ってクラスを定義する最も現代的で推奨される方法は、@attr.define デコレータ(またはエイリアスの @attr.s)と attr.field() 関数(またはエイリアスの attr.ib())を使うことです。@attr.defineattr.field は比較的新しいAPIですが、より明示的で将来性があるため、こちらを使うことが推奨されます。

import attr

@attr.define
class Point:
    x: float = attr.field()
    y: float = attr.field()

# インスタンス化
p1 = Point(1.0, 2.0)
print(p1)  # 出力: Point(x=1.0, y=2.0)

p2 = Point(x=1.0, y=2.0)
print(p1 == p2) # 出力: True
      

このシンプルなコードだけで、以下のことが自動的に行われます。

  • __init__(self, x: float, y: float) メソッドの生成:引数を受け取り、インスタンス変数 self.xself.y に代入します。
  • __repr__(self) メソッドの生成:デバッグに便利な、人間が読みやすい形式の文字列表現(例: Point(x=1.0, y=2.0))を返します。
  • __eq__(self, other), __ne__(self, other) メソッドの生成:すべての属性値が等しい場合に True を返す比較ロジックを実装します。
  • その他、比較メソッド (__lt__, __le__, __gt__, __ge__) やハッシュメソッド (__hash__) も、設定に応じて自動生成されます(デフォルトでは比較メソッドは有効、ハッシュは eq=True かつ frozen=True の場合に生成)。

属性の定義には attr.field() を使います。型アノテーション (: float) と組み合わせることで、より型安全なコードになります。attrs は型アノテーションを認識しますが、実行時の型チェックはデフォルトでは行いません(バリデータを使えば可能です)。

属性にデフォルト値を与えることも簡単です。attr.field()default 引数を使用します。

import attr
from typing import List

@attr.define
class Config:
    path: str = attr.field(default="/etc/myapp.conf")
    retries: int = attr.field(default=3)
    # 可変オブジェクト (リストなど) のデフォルト値には factory を使う
    users: List[str] = attr.field(factory=list) # default=[] は危険!

c1 = Config()
print(c1) # 出力: Config(path='/etc/myapp.conf', retries=3, users=[])

c2 = Config(path="/opt/myapp.conf", users=["admin", "guest"])
print(c2) # 出力: Config(path='/opt/myapp.conf', retries=3, users=['admin', 'guest'])
      

重要: リストや辞書などの可変オブジェクトをデフォルト値にする場合は、default=[]default={} ではなく、factory=listfactory=dict を使用してください。これは、default で指定されたオブジェクトはクラス定義時に一度だけ生成され、すべてのインスタンスで共有されてしまうため、意図しない副作用を引き起こす可能性があるからです。 factory はインスタンスが生成されるたびに新しいオブジェクトを生成します。

型アノテーションを常に使用する場合、@attr.define(auto_attribs=True) を使うと、attr.field() の呼び出しを省略できます(デフォルト値や他のオプションが必要ない場合)。

import attr

@attr.define(auto_attribs=True)
class Vector:
    x: float
    y: float
    z: float = 0.0 # デフォルト値も直接書ける

v = Vector(1.0, 2.0)
print(v) # 出力: Vector(x=1.0, y=2.0, z=0.0)
      

ただし、バリデータやコンバータなどの高度な機能を使いたい場合は、依然として attr.field() を明示的に使う必要があります。

`attrs` の主要機能詳解

attrs の魅力は、基本的なボイラープレート削減だけではありません。より堅牢で柔軟なクラス設計を可能にする、多くの便利な機能を提供しています。

バリデータを使うと、属性に代入される値が特定の条件を満たしているか検証できます。インスタンス生成時や、後から属性に値を代入する際に(設定による)検証が実行されます。

attr.field()validator 引数にバリデーション関数(または関数のリスト)を指定します。バリデーション関数は、instance (インスタンス自身), attribute (属性オブジェクト), value (代入される値) の3つの引数を取ります。値が無効な場合は例外(通常は ValueErrorTypeError)を送出します。

import attr

def is_positive(instance, attribute, value):
    """値が正の数であるか検証するバリデータ"""
    if value <= 0:
        raise ValueError(f"{attribute.name} must be positive, but got {value}")

@attr.define
class PositiveNumber:
    value: float = attr.field(validator=is_positive)

# これはOK
num1 = PositiveNumber(10.5)
print(num1) # 出力: PositiveNumber(value=10.5)

# これは ValueError を送出
try:
    num2 = PositiveNumber(-5.0)
except ValueError as e:
    print(e) # 出力: value must be positive, but got -5.0

# 組み込みバリデータの利用
from attr import validators

@attr.define
class User:
    name: str = attr.field(validator=validators.instance_of(str))
    age: int = attr.field(validator=[validators.instance_of(int), is_positive]) # 複数のバリデータ

user = User(name="Alice", age=30)
print(user) # 出力: User(name='Alice', age=30)

try:
    invalid_user = User(name="Bob", age=-1)
except ValueError as e:
    print(e) # 出力: age must be positive, but got -1

try:
    invalid_user2 = User(name=123, age=25)
except TypeError as e:
    print(e) # 出力: ("'name' must be <class 'str'> (got 123 that is a <class 'int'>).", ...)
      

attrs には validators.instance_of(), validators.optional(), validators.in_() など、便利な組み込みバリデータも用意されています。

注意: デフォルトでは、バリデータはインスタンス初期化時 (__init__) にのみ実行されます。インスタンス作成後に属性へ代入する場合にも検証を行いたい場合は、@attr.define(on_setattr=attr.setters.validate) のように設定する必要があります。

コンバータは、属性に値が代入される前に、その値を自動的に変換する機能です。例えば、文字列で与えられた数値を整数型に変換したり、特定の形式のデータを内部表現に適した形に変換したりするのに役立ちます。

attr.field()converter 引数に変換関数を指定します。変換関数は値 (value) を唯一の引数として受け取り、変換後の値を返します。

import attr
import datetime

def to_datetime(value: str | datetime.datetime) -> datetime.datetime:
    """ISO 8601形式の文字列をdatetimeオブジェクトに変換する"""
    if isinstance(value, datetime.datetime):
        return value
    if isinstance(value, str):
        try:
            return datetime.datetime.fromisoformat(value)
        except ValueError:
            raise ValueError(f"Invalid datetime format: {value}")
    raise TypeError(f"Expected str or datetime, got {type(value)}")

@attr.define
class Event:
    name: str = attr.field()
    # 文字列で受け取った数値を float に変換
    value: float = attr.field(converter=float)
    # ISO 8601 文字列または datetime オブジェクトを datetime オブジェクトに正規化
    timestamp: datetime.datetime = attr.field(converter=to_datetime)

# 文字列が float に変換される
ev1 = Event(name="Measurement", value="123.45", timestamp="2025-04-05T13:01:00+00:00")
print(ev1)
# 出力: Event(name='Measurement', value=123.45, timestamp=datetime.datetime(2025, 4, 5, 13, 1, tzinfo=datetime.timezone.utc))
print(type(ev1.value)) # 出力: <class 'float'>
print(type(ev1.timestamp)) # 出力: <class 'datetime.datetime'>

# datetime オブジェクトはそのまま使われる
now = datetime.datetime.now(datetime.timezone.utc)
ev2 = Event(name="Log", value=0.0, timestamp=now)
print(ev2.timestamp is now) # 出力: True

# 無効な形式はエラー
try:
    Event(name="Error", value="abc", timestamp="invalid-date")
except ValueError as e:
    print(e) # 出力: could not convert string to float: 'abc' (value の変換でエラー)
except TypeError as e:
    print(e) # converter が TypeError を出す場合
      

コンバータはバリデータよりもに実行されます。これにより、変換後の値に対してバリデーションを行うことができます。

注意: デフォルトでは、コンバータはインスタンス初期化時と、属性への再代入時の両方で実行されます。これはバリデータとは異なる挙動です。この挙動を変更したい場合は、@attr.define(on_setattr=...) を使って制御できます(例: on_setattr=[] で代入時のコンバータ実行を無効化)。

@attr.define(frozen=True) を使うと、インスタンス生成後に属性値を変更できない不変 (immutable) なクラスを作成できます。不変オブジェクトは、状態が変化しないことが保証されるため、辞書のキーとして使えたり、マルチスレッド環境で安全に扱えたりするメリットがあります。

import attr

@attr.define(frozen=True)
class ImmutablePoint:
    x: float = attr.field()
    y: float = attr.field()

p = ImmutablePoint(1.0, 2.0)
print(p) # 出力: ImmutablePoint(x=1.0, y=2.0)

# 属性を変更しようとするとエラーになる
try:
    p.x = 5.0
except attr.exceptions.FrozenInstanceError as e:
    print(e) # 出力: ("can't set attribute 'x'", ...)

# 不変なのでハッシュ可能であり、辞書のキーやセットの要素にできる
point_set = {p, ImmutablePoint(3.0, 4.0)}
print(point_set) # 出力例: {ImmutablePoint(x=1.0, y=2.0), ImmutablePoint(x=3.0, y=4.0)}
      

不変オブジェクトの一部を変更したい場合は、attrs.evolve() 関数を使うと、指定した属性だけが異なる新しいインスタンスを効率的に作成できます。

p_new = attr.evolve(p, x=10.0)
print(p_new) # 出力: ImmutablePoint(x=10.0, y=2.0)
print(p)     # 出力: ImmutablePoint(x=1.0, y=2.0) (元のインスタンスは変更されない)
      

@attr.define(slots=True) を指定すると、Python の __slots__ 機能を利用したクラスが生成されます。__slots__ を使うと、以下のメリットがあります。

  • メモリ使用量の削減: インスタンスごとに __dict__ (属性を保持する辞書) を持たなくなるため、メモリフットプリントが小さくなります。大量のインスタンスを生成する場合に特に有効です。
  • 属性アクセス速度の向上: 属性へのアクセスが若干速くなることがあります。
import attr
import sys

@attr.define(slots=True) # slots=True を指定
class SlottedPoint:
    x: float = attr.field()
    y: float = attr.field()

@attr.define(slots=False) # デフォルト (slots=False)
class NormalPoint:
    x: float = attr.field()
    y: float = attr.field()

sp = SlottedPoint(1.0, 2.0)
np = NormalPoint(1.0, 2.0)

# メモリ使用量の比較 (簡易的な測定)
print(f"SlottedPoint size: {sys.getsizeof(sp)}")
print(f"NormalPoint size: {sys.getsizeof(np)} + dict: {sys.getsizeof(np.__dict__)}")
# 出力例 (環境により変動):
# SlottedPoint size: 48
# NormalPoint size: 56 + dict: 104

# slots=True の場合、__dict__ は存在しない
try:
    print(sp.__dict__)
except AttributeError as e:
    print(e) # 出力: 'SlottedPoint' object has no attribute '__dict__'

# slots=True の場合、定義されていない属性の追加はできない
try:
    sp.z = 3.0
except AttributeError as e:
    print(e) # 出力: 'SlottedPoint' object has no attribute 'z'

np.z = 3.0 # 通常のクラスでは可能
print(np.z) # 出力: 3.0
      

slots=True の注意点:

  • クラス定義時に宣言されていない属性を後から追加できなくなります。これは意図しない属性追加を防ぐ効果もありますが、動的な属性追加が必要な場合には使えません。
  • 多重継承などで __slots__ の扱いに注意が必要な場合があります。
  • 一部のライブラリ(特に古いもの)との互換性に問題が生じる可能性があります。

一般的には、パフォーマンスやメモリ効率が重要な場面では slots=True の利用を検討する価値があります。

  • kw_only=True: @attr.define(kw_only=True) とすると、すべての属性がキーワード専用引数となり、インスタンス化時に必ず属性名を指定する必要があります (MyClass(attr1=value1, attr2=value2))。引数の意味が明確になり、順序の間違いを防げます。
  • init=False, repr=False など: attr.field()@attr.define() の引数で、特定の特殊メソッドの自動生成を抑制できます。例えば attr.field(init=False) とした属性は __init__ の引数に含まれなくなります。
  • メタデータ (metadata): attr.field(metadata={'key': 'value'}) のように、属性に追加情報を付与できます。シリアライズライブラリなどがこのメタデータを利用することがあります。

`attrs` vs `dataclasses`

Python 3.7 以降、標準ライブラリに dataclasses モジュールが導入されました。これは attrs に触発されて作られたもので、同様にクラスのボイラープレートコードを削減する目的を持っています。では、どちらを使うべきでしょうか?

  • attrs: 2015年に登場したサードパーティライブラリ。豊富な機能と柔軟性を持ち、長年にわたり多くのプロジェクトで使用されてきました。
  • dataclasses: Python 3.7 (2018年リリース) で標準ライブラリ入り。attrs の主要なアイデアを取り入れつつ、よりシンプルで標準的な機能セットを提供することを目的としています。
機能 attrs dataclasses 備考
基本的なメソッド生成 (__init__, __repr__, __eq__) 基本的な機能は両者とも提供
型アノテーション連携 両者とも型ヒントを主要な定義方法とする
デフォルト値 (default, factory) dataclasses では default_factory
不変オブジェクト (frozen=True)
順序付けメソッド生成 (order=True) __lt__, __le__, __gt__, __ge__
バリデータ (Validators) (強力) attrs の大きな利点の一つ
コンバータ (Converters) attrs の大きな利点の一つ
__slots__ の簡単な利用 (slots=True) (Python 3.10以降で slots=True 可能) attrs は古い Python バージョンでもサポート
キーワード専用引数 (kw_only) (Python 3.10以降)
属性ごとのメソッド生成制御 (柔軟) attrs の方がより細かい制御が可能
依存関係 必要 (サードパーティ) (標準ライブラリ)
Python バージョン互換性 (広い) (Python 3.7+) attrs は Python 2.7 や PyPy もサポート
パフォーマンス (一般に高速) (高速) 両者とも CPython の実装によっては attrs がわずかに速い場合がある。特に slots=True 使用時。

attrsdataclasses はどちらも、クラス定義時にコード生成を行うため、実行時のオーバーヘッドは非常に小さいです。ベンチマークによっては、特に slots=True を使用した場合に attrs がわずかに高速であるという報告もありますが、多くの場合、その差は無視できるレベルです。

Pydantic のようなバリデーションやシリアライズを主目的とするライブラリと比較すると、インスタンス化の速度などでは attrsdataclasses の方が一般的に高速です。

どちらを選択するかは、プロジェクトの要件や状況によって異なります。

`dataclasses` を選ぶ場合:

  • プロジェクトの依存関係を最小限に抑えたい場合(標準ライブラリのみ使用)。
  • Python 3.7 以降の使用が前提とされている場合。
  • バリデータやコンバータのような高度な機能が不要な、シンプルなデータクラスを作成したい場合。
  • 標準ライブラリであることによるツール連携(静的解析ツールなど)の安定性を重視する場合。

`attrs` を選ぶ場合:

  • バリデータやコンバータ機能が必須の場合。
  • slots=True を Python 3.9 以前のバージョンでも簡単に利用したい場合。
  • より細かいカスタマイズや柔軟性が必要な場合。
  • Python 3.7 より前のバージョン(Python 2.7 や PyPy を含む)をサポートする必要がある場合。
  • サードパーティライブラリへの依存が許容される場合。

基本的な機能は共通しているため、dataclasses から attrs への移行(またはその逆)は比較的容易です。プロジェクトの初期段階では dataclasses を使い始め、後からより高度な機能が必要になった場合に attrs に移行するというアプローチも考えられます。

発展的な使い方

attrs には、さらに高度なユースケースに対応するための機能も備わっています。

attrs で定義されたクラスは、通常の Python クラスと同様に継承できます。@attr.define でデコレートされたクラス同士で継承を行うと、属性は適切に引き継がれます。

import attr

@attr.define
class Base:
    a: int = attr.field()

@attr.define
class Derived(Base):
    b: str = attr.field()
    # 親クラスの属性をオーバーライドすることも可能
    # a: float = attr.field(default=0.0)

d = Derived(a=1, b="hello")
print(d) # 出力: Derived(a=1, b='hello')
      

slots=True を使用している場合の継承には注意が必要です。親クラスと子クラスの両方で slots=True を指定するのが一般的です。

attrs インスタンスを辞書やタプルに変換するためのヘルパー関数が用意されています。これらは JSON や他のフォーマットへのシリアライズに役立ちます。

  • attrs.asdict(instance): インスタンスを辞書に変換します。ネストされた attrs インスタンスも再帰的に辞書に変換されます。
  • attrs.astuple(instance): インスタンスをタプルに変換します。
import attr
import json

@attr.define
class Address:
    street: str = attr.field()
    city: str = attr.field()

@attr.define
class Person:
    name: str = attr.field()
    age: int = attr.field()
    address: Address = attr.field()

addr = Address(street="123 Main St", city="Anytown")
person = Person(name="Alice", age=30, address=addr)

# 辞書に変換
person_dict = attr.asdict(person)
print(person_dict)
# 出力: {'name': 'Alice', 'age': 30, 'address': {'street': '123 Main St', 'city': 'Anytown'}}

# JSON にシリアライズ
person_json = json.dumps(person_dict, indent=2)
print(person_json)
# 出力:
# {
#   "name": "Alice",
#   "age": 30,
#   "address": {
#     "street": "123 Main St",
#     "city": "Anytown"
#   }
# }

# タプルに変換
person_tuple = attr.astuple(person)
print(person_tuple)
# 出力: ('Alice', 30, ('123 Main St', 'Anytown'))
      

より複雑なシリアライズ/デシリアライズ(構造化/非構造化)のニーズには、attrs と密接に連携する `cattrs` というライブラリが非常に強力です。cattrs は、辞書やJSONから attrs インスタンスへの変換(デシリアライズ)、およびその逆の変換(シリアライズ)を、型アノテーションに基づいて自動的に、かつ効率的に行うことができます。

# cattrs のインストール: pip install cattrs
import cattrs
import json

converter = cattrs.Converter()

# 辞書から Person インスタンスへデシリアライズ (非構造化)
person_data = {
  "name": "Bob",
  "age": 25,
  "address": {
    "street": "456 Oak Ave",
    "city": "Otherville"
  }
}
bob = converter.structure(person_data, Person)
print(bob) # 出力: Person(name='Bob', age=25, address=Address(street='456 Oak Ave', city='Otherville'))
print(isinstance(bob, Person)) # 出力: True
print(isinstance(bob.address, Address)) # 出力: True

# Person インスタンスから辞書へシリアライズ (構造化)
bob_dict = converter.unstructure(bob)
print(bob_dict == person_data) # 出力: True

# JSON 文字列から直接デシリアライズも可能
json_string = '{"name": "Charlie", "age": 42, "address": {"street": "789 Pine Ln", "city": "Somewhere"}}'
charlie = converter.loads(json_string, Person)
print(charlie) # 出力: Person(name='Charlie', age=42, address=Address(street='789 Pine Ln', city='Somewhere'))
      

cattrs は、日付時刻オブジェクト、Enum、Union型、ジェネリクスなど、様々な型に対する変換ルールを自動または手動で設定でき、非常に柔軟なデータ変換を実現します。

  • __attrs_post_init__(self): __init__ が完了した直後に呼び出されるメソッド。初期化後の追加処理(他の属性値に基づいた属性の計算など)を記述できます。
  • on_setattr フック: @attr.define(on_setattr=...) で、属性への代入時に実行される処理(バリデーション、コンバージョン、カスタムロジックなど)を細かく制御できます。

まとめ

attrs は、Python でクラスを扱う際の定型的な作業を大幅に削減し、開発者がクラスの本質的なロジックに集中できるようにする、非常に強力で成熟したライブラリです。

attrs を使うことで、以下のようなメリットが得られます。

  • コードの簡潔化: __init__, __repr__, __eq__ などの自動生成。
  • 可読性の向上: クラスの意図が明確になり、保守しやすくなります。
  • 堅牢性の向上: バリデータやコンバータによるデータ整合性の確保。
  • 不変性の容易な実現: frozen=True で安全な不変クラスを作成。
  • パフォーマンスとメモリ効率: slots=True による最適化。
  • 高い柔軟性とカスタマイズ性: 豊富なオプションとフック。
  • dataclasses との比較: より多くの機能と広い Python バージョン互換性を提供。

標準ライブラリの dataclasses も素晴らしい選択肢ですが、バリデーション、コンバージョン、古い Python バージョンサポート、あるいは最大限の柔軟性が必要な場合には、attrs がより強力な選択肢となります。

まだ attrs を試したことがない方は、ぜひ次のプロジェクトで導入を検討してみてください。きっとその便利さと強力さに驚くはずです!

さらに詳しい情報や最新の機能については、 attrs 公式ドキュメント を参照してください。