プログラミング
 Computer >> コンピューター >  >> プログラミング >> プログラミング

Pythonでイミュータブル(不変)なデータ構造を実装する方法を徹底解説

はじめに:なぜ不変(イミュータブル)なデータ構造が必要なのか?

並行処理やマルチスレッド環境のプログラミングでは、複数の処理が同時に同じデータへアクセスし、予期せぬ書き換えが発生するリスクがあります。イミュータブル(不変)なデータ構造を使えば、一度作成したデータが勝手に変更されることを防げるため、データの整合性を保つ上で非常に有効です。

一方、リストのようなミュータブル(可変)なデータ構造は、いつでも自由に内容を変更できます。便利である反面、意図しない箇所から書き換えられてしまう危険性も抱えています。

この記事では、Pythonにおける可変・不変データ構造の違いを、実際のコード例を交えながら段階的に解説します。

STEP 01:ミュータブルなリストの挙動を確認する

まずは、通常のPythonリスト(配列)を作成し、要素を変更してみましょう。

# ミュータブルなリストを作成
atp_players = ['Murray', 'Nadal', 'Djokovic']
print(f" *** 元のリストの中身 - {atp_players}")

# Murray を Federer に変更
atp_players[0] = 'Federer'
print(f" *** 変更後のリストの中身 - {atp_players}")

実行結果:

*** 元のリストの中身 - ['Murray', 'Nadal', 'Djokovic']
*** 変更後のリストの中身 - ['Federer', 'Nadal', 'Djokovic']

このように、リストは簡単に値を書き換えられます。自分一人だけが使うデータであれば問題ありませんが、本番環境では複数のプログラムが同じデータにアクセスして変更を行う可能性があり、予想外のデータ破壊につながる恐れがあります。

STEP 02:タプル(Tuple)で不変性を確保する

次に、タプルがどのように振る舞うかを見てみましょう。タプルは要素の代入による変更を許しません。

# タプルへの変更を試みる
try:
    atp_players_tuple = ('Murray', 'Nadal', 'Djokovic')
    print(f" *** 元のタプルの中身 - {atp_players_tuple}")
    atp_players_tuple[0] = 'Federer'
except Exception as error:
    print(f" *** 変更を試みた結果エラー発生 - {error}")

実行結果:

*** 元のタプルの中身 - ('Murray', 'Nadal', 'Djokovic')
*** 変更を試みた結果エラー発生 - 'tuple' object does not support item assignment

ご覧のとおり、タプルは直接変更できません。ただし、ここに一つ重要な例外があります。タプルの中にリストが含まれている場合、そのリスト内部の値は変更できてしまうのです。

注意点:タプル内のリストは変更可能

# タプルの中にリストを入れた場合
atp_players_array_in_tuple = (['Murray'], ['Nadal'], ['Djokovic'])
print(f" *** リスト入りタプルの元データ - {atp_players_array_in_tuple}")

atp_players_array_in_tuple[0][0] = 'Federer'
print(f" *** リスト入りタプルの変更後 - {atp_players_array_in_tuple}")

実行結果:

*** リスト入りタプルの元データ - (['Murray'], ['Nadal'], ['Djokovic'])
*** リスト入りタプルの変更後 - (['Federer'], ['Nadal'], ['Djokovic'])

タプル自体は不変でも、その中身のリストはミュータブルなため、このように値が変わってしまいます。では、どうすればデータを完全に保護できるのでしょうか?答えはシンプルで、リストではなくすべてタプルにすることです。

# すべてタプルで構成した場合
try:
    atp_players_tuple_in_tuple = (('Murray'), ('Nadal'), ('Djokovic'))
    print(f" *** タプル入りタプルの元データ - {atp_players_tuple_in_tuple}")
    atp_players_tuple_in_tuple[0] = 'Federer'
except Exception as error:
    print(f" *** 変更を試みた結果エラー発生 - {error}")

実行結果:

*** タプル入りタプルの元データ - ('Murray', 'Nadal', 'Djokovic')
*** 変更を試みた結果エラー発生 - 'tuple' object does not support item assignment

さらに進んだ方法:NamedTupleを使った不変クラスの作成

PythonにはNamedTupleという便利な組み込みツールがあります。これは継承して使えるクラスで、名前付きフィールドを持つ不変データ構造を簡潔に定義できます。まず、通常のPythonクラスとの違いを見てみましょう。

通常のクラス定義(ミュータブル)

# 通常のPython流の書き方でグランドスラム優勝回数クラスを作成
class GrandSlamsPythonWay:
    def __init__(self, player, titles):
        self.player = player
        self.titles = titles

stats = GrandSlamsPythonWay("Federer", 20)
print(f" *** Stats の内容 - {stats.player} - {stats.titles}")

実行結果:

*** Stats の内容 - Federer - 20

このクラスはイミュータブルでしょうか?FedererをNadalに変更して確認してみます。

stats.player = 'Nadal'
print(f" *** Stats の内容 - {stats.player} - {stats.titles}")

実行結果:

*** Stats の内容 - Nadal - 20

案の定、普通のクラスは値を更新できてしまうため、ミュータブル(可変)です。次に、NamedTupleを使って同じクラスを定義し、デフォルトの挙動を確認しましょう。

NamedTupleを使ったクラス定義(イミュータブル)

from typing import NamedTuple

class GrandSlamsWithNamedTuple(NamedTuple):
    player: str
    titles: int

stats = GrandSlamsWithNamedTuple("Federer", 20)
print(f" *** Stats の内容 - {stats.player} - {stats.titles}")

# 値の変更を試みる
stats.player = 'Djokovic'

実行結果:

*** Stats の内容 - Federer - 20
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
---> 12 stats.player = 'Djokovic'

AttributeError: can't set attribute

NamedTupleで定義したインスタンスは、属性の代入を試みると AttributeError が発生します。これで完全に不変なデータ構造が実現できました。(残念ながらDjokovic選手が20勝目を挙げるのはもう少し先になりそうです。)

_replace()メソッドで「新しいコピー」を作る

ただし、既存の値をもとに別のデータを作りたいケースもあります。その場合は _replace() メソッドを使えば、元のオブジェクトは変更せずに、値を差し替えた新しいコピーを生成できます。

djokovic_stats = stats._replace(player="Djokovic", titles=17)
print(f" *** djokovic_stats の内容 - {djokovic_stats.player} - {djokovic_stats.titles}")

実行結果:

*** djokovic_stats の内容 - Djokovic - 17

このように、元データ stats はFedererのまま保持され、新しく djokovic_stats が作成されます。まさにイミュータブルな設計思想です。

総まとめ:八百屋さんの購入システムで学ぶ実践例

最後に、これまで説明した内容をすべて組み合わせた実践的な例を紹介します。ここでは、八百屋さん向けのソフトウェアを開発していると想定します。

  • Pricesクラス:1商品のID・名前・価格を表す
  • Purchaseクラス:注文IDと、購入された商品(Pricesのタプル)を追跡する
from typing import NamedTuple, Tuple

# 1つの商品を表すクラス
class Prices(NamedTuple):
    id: int
    name: str
    price: int  # 価格(ドル)

# 購入履歴を追跡するクラス
class Purchase(NamedTuple):
    purchase_id: int
    items: Tuple[Prices, ...]

# 商品と価格を定義
carrot = Prices(1, "carrot", 2)
tomato = Prices(2, "tomato", 3)
eggplant = Prices(3, "eggplant", 5)

# 最初のお客さんTom氏はニンジンとトマトを購入
tom_order = Purchase(1, (carrot, tomato))

# Tom氏への請求額を計算
total_cost = sum(item.price for item in tom_order.items)
print(f"*** Tom氏の合計金額 - {total_cost}$")

実行結果

*** Tom氏の合計金額 - 5$

この例では、商品情報も注文情報もすべてNamedTupleで定義しているため、誰かが途中で価格や注文内容を不正に書き換えることはできません。データは生成時の状態のまま安全に保持され、必要に応じて _replace() で新しいバージョンを作成できます。

まとめ

本記事で学んだポイントを整理します。

  • リストはミュータブルで自由に変更できるが、並行処理ではデータ競合のリスクがある
  • タプルはイミュータブルだが、内包するリストまでは保護できない
  • 完全な不変性が必要なら、ネストする要素もすべてタプルにする
  • NamedTupleを使えば、型ヒント付きの読みやすい不変データクラスを簡潔に定義できる
  • 既存データから派生データを作りたい場合は _replace() メソッドを活用する

イミュータブルなデータ構造を適切に活用することで、バグの少ない堅牢で予測可能なPythonコードを実現できます。特に並行処理や大規模なコードベースでは、その効果を実感できるでしょう。

  1. Pythonの回帰モデルに非線形データを適合させる方法をわかりやすく解説

    データの可視化には、Seabornライブラリを使用します。回帰モデルを構築する際には、必ず多重共線性(マルチコリニアリティ)の有無をチェックしましょう。これは、連続変数のあらゆる組み合わせ間に存在する相関関係を把握しておく必要があるためです。もし変数間に多重共線性が確認された場合は、データから除去する対策が求められます。 現実世界のデータは非線形であることが多い 実際のビジネスや研究の現場で扱うデータは、多くの場合非線形です。したがって、こうした非線形データをモデルへ適切に適合させる仕組みを見つけることが重要になります。本記事では、統計学で有名なAnscombe(アンスコム)のデータセットを用

  2. Python(Matplotlib)でグラフに凡例を追加する方法を徹底解説

    はじめにグラフの最大の目的は、データをわかりやすく伝えることです。「百聞は一見に如かず」という言葉のとおり、言葉では表現しにくい複雑な情報も、一枚の図やグラフがあれば直感的に伝えることができます。しかし、多くの情報を含むグラフを描く場合は、どのデータがどの要素に対応しているのかを読み手が理解できるよう、凡例(legend)を添えると効果的です。準備:matplotlibのインストールとデータ作成matplotlibでは、凡例をさまざまな方法で表示できます。また、特定のポイントに注目させたい場合には注釈(annotation)も有効で、グラフ上の情報を読み手が理解する助けになります。まず、Pyt