1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

お題は不問!Qiita Engineer Festa 2024で記事投稿!
Qiita Engineer Festa20242024年7月17日まで開催中!

【Python】”とりあえずリスト病”の治療1:set型, frozenset型について

Last updated at Posted at 2024-06-22

前書き

「計算結果を一時的に保存しておきたいから、とりあえずリストを使っておこう。」
「ユーザーを集計する機能はできたから、とりあえずリストを置いておこう。」
「メソッドの返り値?とりあえずリストに保存しておこう。」

以上の様な”とりあえずリスト病”は、Python初心者あるあるだと思っています。私もバッチリ罹患しています。もしかしたら、これを読んでいただいている方の中にもご同類がいらっしゃるかもしれませんね。

確かにリストは非常に柔軟で懐が深いデータコンテナです。乱暴な言い方をしてしまえば、キーを使った呼び出しが必要な場合以外は大体リストで事足ります。

ただし、当たり前の話ではありますが、Python側が他のコンテナを多数実装している以上、それぞれを使い分けることが期待されています。そして、その使い分けを可能にするためには、それぞれの性質をしっかりと理解する必要があるでしょう。

そこで、何回かに分けてデータコンテナを取り扱って、理解を深めていこうと思います。初回は全く扱った記憶がないset型からです。よろしくお願いします。

set型

Pythonにはset型と呼ばれる組み込み型があります。特徴としては、

  • 全ての要素がユニーク
  • 全てのの要素がハッシュ可能なものでなければならない
  • シーケンスなオブジェクトではない
  • ミュータブルなオブジェクトである
    • 要素の挿入や削除、検索などが高速に行える

の4+1つが主に挙げられるでしょう。まずは作成方法から入り、その後残りについて確認していきます。

作成方法

まずは作成方法です。いくつかやり方があります。

一番シンプルなのは、{}(波括弧)の中に要素を直接記述する方法です。

example_set1 = {1, 2, 3}

他には、set()を用いる方法もあります。この場合、引数としてはイテラブルオブジェクトを与える必要があります。

example_set1 = set([1, 2, 3])
example_set2 = set((1, 2, 3))
example_set3 = set({1, 2, 3})

イテラブルオブジェクトでさえあればよいので、外部ファイルを読み込む自作クラスも利用可能です。

numbers.txt
1
2
3
class ReadNumbers:
    def __init__(self, file_path):
        self.file_path = file_path
    
    def __iter__(self):
        with open(self.file_path) as f:
            for line in f:
                yield int(line)

numbers = ReadNumbers("numbers.txt")
example_set = set(numbers)

さらに、内包表記にも対応しています。

example_set = {i for i in (1, 2, 3)}

なお、要素を持たない集合はset()でしか作成できません。{}で作成しようとすると、dict型のインスタンスとなります。

s1 = set()
print(type(s1))
s2 = {}
print(type(s2))
<class 'set'>
<class 'dict'>

全ての要素がユニークかつハッシュ可能である

要素の条件ということで一つにまとめましたが、実は微妙にニュアンスが異なります。というのも、ユニークは重複している要素を勝手に消して実現してくれますが、ハッシュ可能かどうかは使用者側で配慮する必要があります。

ユニーク

set型の要素は全てユニーク、一意なものになっています。要するに重複する要素を保持することはありません。

s = set([1, 1, 2, 2, 3])
print(s)
{1, 2, 3}

見ての通り、1も2も一つだけになっていますね。シンプルな性質ながら、set型の根幹を占めるものになっていますので、非常に重要です。

ハッシュ可能性

次に、ハッシュ可能性についてです。これは要するに、途中で変化するような要素はNGということです。公式ドキュメントには以下のように記載されています。

An object is hashable if it has a hash value which never changes during its lifetime (it needs a __hash__() method), and can be compared to other objects (it needs an __eq__() method). Hashable objects which compare equal must have the same hash value.

オブジェクトの存在期間にハッシュ値が変わらない(__hash__()メソッドが必要)、さらに他のオブジェクトと比較可能(__eq__()メソッドが必要)であるとき、そのオブジェクトはハッシュ可能です。等価なハッシュオブジェクトは、同一のハッシュ値を持たなければなりません。

Hashability makes an object usable as a dictionary key and a set member, because these data structures use the hash value internally.

ハッシュ可能であるとき、オブジェクトは辞書のキーやset型の要素として利用可能です。というのも、内部的にはこれらの構造はハッシュ値を利用しているからです。

Most of Python’s immutable built-in objects are hashable; mutable containers (such as lists or dictionaries) are not; immutable containers (such as tuples and frozensets) are only hashable if their elements are hashable. Objects which are instances of user-defined classes are hashable by default. They all compare unequal (except with themselves), and their hash value is derived from their id().

Pythonのほとんどのイミュータブルな組み込みオブジェクトはハッシュ可能です。リストや辞書などの、ミュータブルなデータコンテナはハッシュ不可です。タプルやfrozensetsなどのイミュータブルなコンテナは、含まれている要素がハッシュ可能な時に限りハッシュ可能です。ユーザーが定義したクラスは、デフォルトではハッシュ可能です。それらは、自身を除いて全てのオブジェクトと等しくないと判定されます。また、ハッシュ値はid()から計算されることになります。

といった具合です。なお、「イミュータブルなコンテナのハッシュ可能性」については以下に折りたたんでおきます。

イミュータブルなコンテナのハッシュ可能性

タプルはシーケンスかつイミュータブルなオブジェクトです。

sample_tuple= (1, 2, 3)
print(sample_tuple[0])
sample_tuple[0] = "a"
1
...
TypeError: 'tuple' object does not support item assignment

ハッシュ値を取得することもできます。

sample_tuple = (1, 2, 3)
print(hash(sample_tuple))
529344067295497451

ただし、タプルの要素は必ずしもハッシュ可能である必要がありません。つまり、要素として、リストなどのミュータブルなオブジェクトを含むことが可能ですが、ハッシュ値を取ることはできなくなります。

list_in_tuple = ["a", "b"]
sample_tuple = (1, 2, list_in_tuple)
print(hash(sample_tuple))
TypeError: unhashable type: 'list'

というのも、タプルやリストなどのデータコンテナは、あくまでオブジェクトへの参照を格納しているだけなので、相手方が変更されれば、当然それが反映されます。

sample_list = ["a", "b"]
sample_tuple = (1, 2, sample_list)
print(sample_tuple)
sample_list[0] = 1
print(sample_tuple)
(1, 2, ['a', 'b'])
(1, 2, [1, 'b'])

値がコロコロ変わるため、ハッシュ不可能とされているわけです。

ちなみに、あまり関係はないのですが、ミュータブルな要素を含んだタプルに対し、複合代入演算子(累積代入演算子)を使うと、別口の怪しい挙動が発生したりもします。

sample_list = ["a", "b"]
sample_tuple = (1, 2, sample_list)

try:
    sample_tuple[2] += ["c", "d"]
except TypeError as e:
    print(f"Caught an error: {e}")

print(sample_tuple)
Caught an error: 'tuple' object does not support item assignment
(1, 2, ['a', 'b', 'c', 'd'])

+=の右側のリストへの追加が行われた後にタプルへの再代入が行われるため、TypeErrorを吐きつつも、内部のリスト自体は変更されているというよくわからない状況になります。

少し逸れたので、話を戻しましょう。タプル自体はレコード的な記録を行う際に、様々な型のデータを格納することは珍しくありません。しかし、ここまで見てきたように想定外の挙動につながることがあるため、ミュータブルな型を含むのは避けた方が無難でしょう。これさえ避けておけば、自然とハッシュ可能にもなります。

なお、要素が全てイミュータブルなもので統一されているかを知りたければ、以下のようなシンプルな関数で確認可能です。

# hashable
def is_all_immutable(target):
    try:
        hash(target)
    except TypeError:
        return False
    return True

class Sample:
    pass


assert is_all_immutable((1, 2, 3)) == True
assert is_all_immutable((1, 2, 3, ["a", "b"])) == False
sample = Sample()
assert is_all_immutable((1, 2, 3, sample)) == True

----------折りたたみここまで----------

一番手っ取り早いのは、hash()を呼び出して、エラーを吐かないかどうかでチェックするやり方です。問題なくハッシュ値を取得できればハッシュ可能です。

print(hash(1))
print(hash("a"))

ハッシュ不可能である場合は、TypeErrorを吐きます。

print(hash([1, 2, 3]))
TypeError: unhashable type: 'list'

そして、set型は全ての要素にハッシュ可能であることを求めます。

example_set = {1, 2, 3, "a", "b", "c"}

そのため、リストなどのミュータブルなオブジェクトを要素に含むことは許されていません。仮に含んでいた場合は、以下のようにTypeErrorを吐きます。

sample_set = {1, 2, ["a", "b"]}
TypeError: unhashable type: 'list'

なお、自作クラスでhashを用いたい場合は、ドキュメントにある通り、__hash__と__eq__を自分で実装すればOKです。

class User:
    def __init__(self, name, email):
        self.name = name
        self.email = email
    
    def __repr__(self):
        return f"User({self.name!r}, {self.email!r})"
    
    def __hash__(self):
        return hash((self.name, self.email))
    
    def __eq__(self, other):
        return (self.name == other.name) and (self.email == other.email)


Alice = User("Alice", "Alice1234@example.com")
Bob = User("Bob", "Bob1234@company.co.jp")
assert Alice != Bob
users = {Alice, Bob}
print(users)
{User('Bob', 'Bob1234@company.co.jp'), User('Alice', 'Alice1234@example.com')}

__hash__はその名の通りhash値を返すメソッドで、複数の変更が想定されない属性を併用するのが一般的になります。一方で__eq__はオブジェクトの等価性を定義するメソッドで、ハッシュが衝突した際に、本当にオブジェクトが等しいのか、あるいは異なるオブジェクトがたまたま同じハッシュ値を持ってしまったのかを判定します。

以上の2つを併用し、ハッシュ値を取得→衝突したら「同じオブジェクトだから衝突した」のか、「異なるオブジェクトなのに衝突した」のかを判定します。前者については何も行われませんが、後者については新しい配置先を探すようになっています(オープンアドレス法)。実装は読めなかったので完全にはわかりませんでしたが、どうやらインデックスに平方数を足して探し直すことになるようです。

まとめ

set型は全ての要素がユニークかつハッシュ可能でなければなりません。ユニークでないものについては勝手にまとめてくれますが、ハッシュ可能かについては自分でケアしておかないと、エラーを吐いてしまいます。

シーケンスなオブジェクトではない

リストのように、要素が順番を持つオブジェクトをシーケンス型のオブジェクトといいます。有名どころはリストとタプルで、いずれも順番を介して要素にアクセスすることが可能です。

sample_list = ["a", "b", "c"]
assert sample_list[0] == "a"
sample_tuple = (2, 4, 6)
assert sample_tuple[0] == 2

一方で、set型はシーケンスなオブジェクトではありません。そもそも、入力時の順番が保存されていません。

sample_set = {"a", 1, "b", 2}
print(sample_set)
{'b', 1, 2, 'a'}

インデックスで呼び出そうものなら、当然エラーを吐きます。

sample_set = {"a", "b", "c"}
print(sample_set[0])
TypeError: 'set' object is not subscriptable

後述しますが、set型は要素の挿入・削除、検索などに特化して作られているため、順番の保持などはそもそも想定されていません。これらを実現するために、ハッシュ値の計算以外にも、モジュロ演算などが行われているようです。

深淵を覗いてみたい方は、GitHubのCpythonリポジトリを閲覧されてみてください。私は何もわかりませんでした……

set型で利用可能な操作

set型では様々な操作が利用可能です。

存在判定

set型ではinを使った存在判定が可能です。これはリスト型やタプル型でも可能なのですが、set型はとにかく爆速で検索してくれます。

from time import perf_counter

test_targets = (list(range(100000)), tuple(range(100000)), set(range(100000)))
for test_target in test_targets:
    start_time = perf_counter()
    print("aaaaaa" in test_target)
    end_time = perf_counter()
    print(f"time: {end_time - start_time:.9f}s")
    print("--------------------")
False
time: 0.002535800s
--------------------
False
time: 0.002214200s
--------------------
False
time: 0.000003900s
--------------------

試しに1000回の平均を取ると、以下のような感じになりました。

list tuple set
0.001873620秒 0.001632995秒 0.000000240秒

とにかく早いですね。

部分集合・真部分集合

ある集合が、他の集合の部分集合や真部分集合であるかどうか、という判定も行えます。<が真部分集合で、<=が部分集合です。真部分集合は部分集合とは異なり、不等号が開いている方の集合が、閉じている方の集合が持たない要素を持っている必要があります。

sample_set1 = {1, 2}
sample_set2 = {1, 2, 3}
assert (sample_set1 < sample_set2) == True
assert (sample_set1 <= sample_set2) == True
assert (sample_set1 < sample_set1) == False
assert (sample_set1 <= sample_set1) == True

set型以外と判定を行おうとすると、TypeErrorを吐きます。

sample_set = {1, 2}
sample_list = [1, 2, 3]
print(sample_set < sample_list)
TypeError: '<' not supported between instances of 'set' and 'list'

一方で、issubsetメソッドを用いれば、異なる型のイテラブルなオブジェクトに対して部分集合の判定を行うことが可能です。

sample_set = {1, 2}
sample_list = [1, 2, 3]
sample_tuple = (1, 2, 3)
assert sample_set.issubset(sample_list) == True
assert sample_set.issubset(sample_tuple) == True

この演算子だと同じset型に限定されるが、メソッドであればイテラブルオブジェクトに対応可能というのは以降のメソッドでも共通しています。

積集合・和集合

set型には集合演算のようなものも実装されています。というか、set自体が集合という意味があるので、それに伴う処理は自然と用意されている形です。

まずは積集合です。2つの集合から共通するものを抽出します。

congluent_to_3_modulo_4 = {4 * i + 3 for i in range(1, 100)}
congluent_to_2_modulo_3 = {3 * i + 2 for i in range(1, 100)}
print(congluent_to_3_modulo_4 & congluent_to_2_modulo_3)
{131, 263, 11, 143, 275, 23, 155, 287, 35, 167, 299, 47, 179, 59, 191, 71, 203, 83, 215, 95, 227, 107, 239, 119, 251}

メソッドを利用することも可能です。

print(congluent_to_3_modulo_4.intersection(congluent_to_2_modulo_3))
{131, 263, 11, 143, 275, 23, 155, 287, 35, 167, 299, 47, 179, 59, 191, 71, 203, 83, 215, 95, 227, 107, 239, 119, 251}

いずれも3つ以上のset型に対して利用可能です。

multiple_of_3 = {i for i in range(1, 100) if i % 3 == 0}
multiple_of_4 = {i for i in range(1, 100) if i % 4 == 0}
multiple_of_5 = {i for i in range(1, 100) if i % 5 == 0}
print(multiple_of_3 & multiple_of_4 & multiple_of_5)
print(multiple_of_3.intersection(multiple_of_4, multiple_of_5))
{60}
{60}

そして、和集合についても全く同じことが可能です。こちらの演算子は|(バーティカルバー)で、メソッドはunionになります。

multiple_of_3 = {i for i in range(1, 100) if i % 3 == 0}
multiple_of_4 = {i for i in range(1, 100) if i % 4 == 0}
multiple_of_5 = {i for i in range(1, 100) if i % 5 == 0}
print(multiple_of_3 | multiple_of_4 | multiple_of_5)
print(multiple_of_3.union(multiple_of_4, multiple_of_5))
{3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 21, 24, 25, 27, 28, 30, 32, 33, 35, 36, 39, 40, 42, 44, 45, 48, 50, 51, 52, 54, 55, 56, 57, 60, 63, 64, 65, 66, 68, 69, 70, 72, 75, 76, 78, 80, 81, 84, 85, 87, 88, 90, 92, 93, 95, 96, 99}
{3, 4, 5, 6, 8, 9, 10, 12, 15, 16, 18, 20, 21, 24, 25, 27, 28, 30, 32, 33, 35, 36, 39, 40, 42, 44, 45, 48, 50, 51, 52, 54, 55, 56, 57, 60, 63, 64, 65, 66, 68, 69, 70, 72, 75, 76, 78, 80, 81, 84, 85, 87, 88, 90, 92, 93, 95, 96, 99}

差集合

2つの集合の差を取ることもお手の物です。

multiple_of_3 = {i for i in range(20) if i % 3 == 0}
multiple_of_4 = {i for i in range(20) if i % 4 == 0}
print(multiple_of_3 - multiple_of_4)
{3, 6, 9, 15, 18}

対称差

いずれか一方にのみ含まれる要素を取り出すことも可能です。XORと同じですね。

sample_set1 = {"a", "b", "c"}
sample_set2 = {"c", "d", "e"}
print(sample_set1 ^ sample_set2)
{'b', 'd', 'e', 'a'}

メソッドもしっかり用意されています。

sample_set1 = {"a", "b", "c"}
sample_set2 = {"c", "d", "e"}
print(sample_set1.symmetric_difference(sample_set2))

ミュータブルなオブジェクトである

set型はミュータブル、つまり変更可能なオブジェクトです。ユニークかつハッシュ可能なオブジェクトであれば、追加したり削除したりすることが可能です。

追加

単純に要素を追加したい場合はaddメソッドを利用します。

sample_set = {1, 2, 3}
sample_set.add("a")
print(sample_set)
{'a', 1, 2, 3}

ならば、と思い+での演算を行いたくなるのですが、こちらはサポートされていません。

sample_set1 = {1, 2, 3}
sample_set2 = {3, 4, 5}
print(sample_set1 + sample_set2)
TypeError: unsupported operand type(s) for +: 'set' and 'set'

リストやタプルなどのように単純な連結ではないため、区別しているのかもしれませんね。

まとめていきたい場合は|=か、updateメソッドを使いましょう。メソッドの方は引数にはイテラブルオブジェクトを取ります。文字列だと1文字ずつ処理される例のアレになります。

sample_set1 = {1, 2, 3}
sample_set2 = {3, 4, 5}
sample_set1 |= sample_set2
print(sample_set1)
{1, 2, 3, 4, 5}
sample_set = {1, 2, 3}
sample_set.update(["a", "b", "c"])
sample_set.update((10, 20, 30))
sample_set.update(range(11, 16))
sample_set.update("xyz")
print(sample_set)
{'a', 1, 2, 3, 'z', 'c', 10, 11, 12, 13, 14, 15, 'x', 20, 'b', 'y', 30}

集合の演算と組み合わせることも可能です。

set1 = {1, 2, 3}
set2 = {3, 4, 5}
set1 &= set2
print(set1)
set3 = {"a", "b", "c"}
set4 = {"b", "c", "d"}
set3.intersection_update(set4)
print(set3)
{3}
{'b', 'c'}

複数の要素でも問題ありません。メソッドであれば他のイテラブルオブジェクトでOKというのも共通しています。

set1 = {1, 2, 3}
set2 = {2, 3, 4}
set3 = {3, 4, 5}
set1 &= set2 & set3
print(set1)
set4 = {"a", "b", "c"}
list1 = ["b", "c", "d"]
tuple1 = ("c", "d", "e")
set4.intersection_update(list1, tuple1)
print(set4)
{3}
{'c'}

要素の削除

削除は一捻りというか、少し注意が必要です。というのも、すでに存在している要素を追加しても特に文句は言われませんが、

sample_set = {1, 2, 3}
sample_set.add(1)
sample_set.update([1, 2, 3])
print(sample_set)
{1, 2, 3}

削除のうち、removeの方は存在しない要素を削除しようとすると、KeyErrorを吐きます。

sample_set = {1, 2, 3}
sample_set.remove(1)
print(sample_set)
sample_set.remove("a")
{2, 3}
...
KeyError: 'a'

存在するかわからない要素を削除したい場合は、discardの方にお願いしましょう。

sample_set = {1, 2, 3}
sample_set.remove(1)
print(sample_set)
sample_set.discard("a")
print(sample_set)
{2, 3}
{2, 3}

複数の要素を一度に削除したい場合は、difference_updateに頼りましょう。引数はイテラブルオブジェクトならOKで、挙動としてはdiscardと同じく、存在していなくても特に文句は言われません。

sample_set = set(range(12))
sample_set.difference_update([0, 1])
print(sample_set)
sample_set.difference_update((2, 3))
print(sample_set)
sample_set.difference_update({4, 5})
print(sample_set)
sample_set.difference_update({6: "a", 7: "b"})
print(sample_set)
sample_set.difference_update({"a": 8, "b": 9}.values())
print(sample_set)
sample_set.difference_update({10: "a", 11: "b"}.keys())
print(sample_set)
sample_set.difference_update({"a", "b"})
print(sample_set)
{2, 3, 4, 5, 6, 7, 8, 9, 10, 11}
{4, 5, 6, 7, 8, 9, 10, 11}
{6, 7, 8, 9, 10, 11}
{8, 9, 10, 11}
{10, 11}
set()
set()

個人的に少し混乱してしまったのがpop周りです。公式ドキュメントには、

Remove and return an arbitrary element from the set. Raises KeyError if the set is empty.

set型から任意の要素を取り除き、返します。もしset型が空になっている場合は、KeyErrorを送出します。

とありますが、具体的には初めの値を返してきています。

sample_set = {10, "a", 20, "b", 30, "c"}
print(sample_set)
print(sample_set.pop())
print(sample_set)
print(sample_set.pop())
print(sample_set)
{'c', 'a', 'b', 20, 10, 30}
c
{'a', 'b', 20, 10, 30}
a
{'b', 20, 10, 30}

元々pop自体がスタック構造でLIFO(後入れ先出し)を行うときに使われる用語ではあるので、確かに初めの要素を返してきているのは言葉通りですね。ただ、「順番を保持していないset型に後も先も無いのでは……?」という感覚が拭えなくて混乱しました。

ちなみに、すでに述べた通り、set型は要素の扱いを効率化することを目的に設計されていますので、別にランダムに要素を含むわけではありません。インタープリターを再起動してシード値が切り替わらない限り、同じ要素群は同じ順番で保持されます。

for _ in range(5):
    sample_set = {"a", "b", "c"}
    print(sample_set.pop())

for _ in range(5):
    sample_set = {1, 2, 3}
    print(sample_set.pop())

for _ in range(5):
    sample_set = {1, 2, 3, "a", "b", "c"}
    print(sample_set.pop())
c
c
c
c
c
1
1
1
1
1
1
1
1
1
1

なので、「ランダムな要素を選択したい」という目的なのであれば、popは必ずしも適切とは限りません。

以下のように、リスト型に変換するのがわかりやすい解決策です。


from random import choice, sample

sample_set = {"a", "b", "c", "d", "e"}
print(choice(list(sample_set)))
print(sample(list(sample_set), 3))
b
['c', 'e', 'b']

メモリ消費が気になるようなデータのサイズであれば、少し凝ったやり方も可能です。具体的には、randrangeでランダムに範囲を指定し、itertools.isliceでイテレータを作成、nextで要素を呼び出す事が可能です。(randintでも動かせますが、両端を含む関係上、スライス指定とはイマイチかみ合わせが悪い印象になります)

from random import randrange
from itertools import islice


sample_set = set(range(1000000))
random_index = randrange(len(sample_set))
random_iterator = islice(sample_set, random_index, None)
print(next(random_iterator))

なお、しれっと使っているitertools.isliceについては、以下に折りたたんでおきます。ご存じない方はご参照下さい。

itertools.isliceについて

標準ライブラリのitertoolsに含まれている関数です。使用用途としては、イテラブルなオブジェクトの一部を切り取り、イテレータを生成するために利用されます。公式ドキュメントには以下のように記載されています。

Make an iterator that returns selected elements from the iterable. Works like sequence slicing but does not support negative values for start, stop, or step.

イテラブルから、選択された要素を返すイテレータを作成します。シーケンスのスライスのように動作しますが、start(開始位置)、stop(停止位置)、step(間隔)のいずれに対しても負の値を用いることはできません。

If start is zero or None, iteration starts at zero. Otherwise, elements from the iterable are skipped until start is reached.

startがゼロ、あるいはNoneである場合、繰り返しは0から始まります。そうでない場合は、開始位置までイテラブルの要素はスキップされます。

If stop is None, iteration continues until the iterator is exhausted, if at all. Otherwise, it stops at the specified position.

stopがNoneである場合は、(終わりがあるものに関しては)イテレータが空になるまで繰り返しが続きます。そうでない場合は、特定の位置で停止します。

If step is None, the step defaults to one. Elements are returned consecutively unless step is set higher than one which results in items being skipped.

もし、stepがNoneである場合は、デフォルトの1に設定されます。この場合、要素は連続して返されますが、stepが1より高く設定されている場合は、アイテムが飛ばされることになります。

定義としては、

itertools.islice(iterable, stop)

もしくは、

itertools.islice(iterable, start, stop[, step])

となります。個人的にですが、2から3個の段階でstartとstopの位置が変わるような実装は珍しいような気がします。他にはrangeやsliceくらいでしょうか?基本は拡張の方向で話が進むイメージがあるのですが、これもまた何かしらの配慮によるものなのでしょうか。

話を戻して、実際に動かしてみます。

from itertools import islice
from string import ascii_uppercase

print(ascii_uppercase)
# islice(iterable, stop)
extracted1 = islice(ascii_uppercase, 5)
print(list(extracted1))
# islice(iterable, start, stop)
extracted2 = islice(ascii_uppercase, 5, 8)
print(list(extracted2))
# islice(iterable, start, stop, step)
extracted3 = islice(ascii_uppercase, 0, 9, 2)
print(list(extracted3))
ABCDEFGHIJKLMNOPQRSTUVWXYZ
['A', 'B', 'C', 'D', 'E']
['F', 'G', 'H']
['A', 'C', 'E', 'G', 'I']

文字列はイテラブルなので、問題なくisliceを適用可能です。そして、与えている位置引数の個数が2個の場合はstopを指定したことになり、3個の場合はstartとstopを、4つの場合はstart, stop, stepを指定したことになります。

とはいえ、今回の例であれば、スライス表記、あるいはスライスオブジェクトでも問題なく対応可能です。文字列はイテラブルなだけでなく、シーケンスなオブジェクトでもあるからです。

from itertools import islice
from string import ascii_uppercase


extracted1_0 = list(islice(ascii_uppercase, 5))
extracted1_1 = ascii_uppercase[0:5]
extracted1_2 = ascii_uppercase[slice(0, 5)]
assert extracted1_0 == list(extracted1_1) == list(extracted1_2)

そのため、islice独自の強みとなると、例として扱ったset型や、イテレータなどが挙げられるでしょう。

from itertools import islice


def count_up():
    count = 0
    while True:
        yield count
        count += 1


it = islice(count_up(), 50, 100, 2)
print(list(it))
[50, 52, 54, 56, 58, 60, 62, 64, 66, 68, 70, 72, 74, 76, 78, 80, 82, 84, 86, 88, 90, 92, 94, 96, 98]

この例では0から無限に数え上げるイテレータを作成し、それを特定のstart, stop, stepで読み込んでいます。

また、ファイルオブジェクトも活用対象の一つです。以下のような「店舗名 日付 売上」で記録されている適当な売上ファイルがある場合、

sales.txt
Shinjuku 2024/06/01 2836
Shibuya 2024/06/01 4448
Meguro 2024/06/01 6275
Shinjuku 2024/06/02 2718
Shibuya 2024/06/02 4829
Meguro 2024/06/02 8505
Shinjuku 2024/06/03 8394
Shibuya 2024/06/03 8593
Meguro 2024/06/03 7062
Shinjuku 2024/06/04 9999
...
from itertools import islice

file_path = "sales.txt"
start_line = 5
end_line = 20
step = 3

with open(file_path, 'r') as file:
    for line in islice(file, start_line, end_line, step):
        print(line.strip())
Meguro 2024/06/02 8505
Meguro 2024/06/03 7062
Meguro 2024/06/04 9448
Meguro 2024/06/05 4894
Meguro 2024/06/06 9912

以上のように、特定の行だけを抜き出して処理することも可能です。この手法は、データサイズが非常に大きく、かつ生成についてはこちらに制御権がない場合に有効です。

話をまとめると、シーケンスに限らず、幅広いイテラブルオブジェクトでイテレータを作成してくれるのがislice関数です。

------------------------折りたたみここまで-----------------------------

frozenset型

ここまでset型の性質や演算について語ってきましたが、Pythonには兄弟とも言うべき存在、frozenset型があります。こちらはset型ではあるものの、イミュータブルなオブジェクトとなっている点で大きく異なります。

生成方法

set型とは異なり、frozenset型にリテラルは用意されていません。コンストラクタを呼び出しましょう。引数はイテラブルオブジェクトです。

fs1 = frozenset([1, 2, 3])
print(fs1)

ちなみに、両者共にcollections.abc.Setを継承しており、要素が同じであればTrueという判定になります。

fs1 = frozenset([1, 2, 3])
print(issubclass(set, frozenset))
print(issubclass(frozenset, set))
s1 = set([1, 2, 3])
print(fs1 == s1)
False
False
True

演算やメソッド

set型と同じく集合なので、似たような計算は概ねできる傾向があります。ただ、もちろん異なる部分もあります。せっかくなので、ここではset型との対比を交えて確認してみましょう。

まずは、それぞれのメソッドのうち、特殊メソッドでないものを抜き出してみます。

def is_not_special_method(string):
    return not string.startswith('__')

set_operations = {operation for operation in dir(set) if is_not_special_method(operation)}
frozenset_operations = {operation for operation in dir(frozenset) if is_not_special_method(operation)}
print("---set operations---")
print(set_operations)
print("---frozenset operations---")
print(frozenset_operations)
---set operations---
{'copy', 'issuperset', 'symmetric_difference_update', 'discard', 'intersection', 'isdisjoint', 'clear', 'pop', 'remove', 'difference', 'symmetric_difference', 'update', 'union', 'intersection_update', 'issubset', 'add', 'difference_update'}
---frozenset operations---
{'copy', 'issuperset', 'intersection', 'isdisjoint', 'difference', 'symmetric_difference', 'union', 'issubset'}

ここで、set型とfrozenset型のメソッドのうち、「共通のもの」、「set型に特有のもの」、「frozenset型に特有のもの」を抜き出してみましょう。

print("---common operations---")
for operation in (set_operations & frozenset_operations):
    print(operation)
print("---set only operations---")
for operation in (set_operations - frozenset_operations):
    print(operation)
print("---frozenset only operations---")
for operation in (frozenset_operations - set_operations):
    print(operation)
---common operations---
copy
issuperset
intersection
isdisjoint
difference
symmetric_difference
union
issubset
---set only operations---
symmetric_difference_update
discard
add
clear
pop
update
remove
intersection_update
difference_update
---frozenset only operations---

以上を見ていただくと、要素を変更する操作は軒並みset限定となっていて、逆にfrozenset型特有のメソッドは存在しないことがわかります。そのため、"できることの多さ"という観点で言えば、ともすればfrozenset型はset型の下位互換として扱われてしまうかもしれません。

なお、変更に関連しているものの、少しややこしい累積代入演算子周りの話は以下に折りたたんでおきます。

frozensetに対する累積代入演算子の適用

frozensetに&=や|=のような累積代入演算子を適用すると、一見中身が変わっているように見えます。

fs1 = frozenset(["a", "b", "c"])
fs2 = frozenset(["b", "c", "d"])
fs1 &= fs2
print(fs1)
frozenset({'b', 'c'})

ですが、これは見た目にそう見えているだけで、実際には新たなfrozensetオブジェクトを生成し、fs1という変数名を再バインドしているだけになります。組み込み関数のidを利用するとわかりやすくなります。

fs1 = frozenset(["a", "b", "c"])
print(f"before id: {id(fs1)}")
fs2 = frozenset(["b", "c", "d"])
fs1 &= fs2
print(f"after id: {id(fs1)}")
print(fs1)
before id: 2584803034048
after id: 2584789171360
frozenset({'b', 'c'})

ミュータブルなset型ではこれは発生しません。

s1 = set(["a", "b", "c"])
print(id(s1))
s2 = set(["b", "c", "d"])
s1 &= s2
print(id(s1))
print(s1)
before id: 2584803033152
after id: 2584803033152
{'b', 'c'}

累積代入演算子を適用すると、新しいオブジェクトを生成し、再バインドが行われる。これはイミュータブルなオブジェクトに共通の動作となります。

t1 = tuple([1, 2, 3])
print(f"before id: {id(t1)}")
t2 = tuple(["a", "b", "c"])
t1 += t2
print(f"after id: {id(t1)}")
print(t1)
before id: 2584803187456
after id: 2584803061632
(1, 2, 3, 'a', 'b', 'c')

以上のようにとりあえず適用可能であるとはいえ、コスト的にはマイナスですし、何よりイミュータブルなオブジェクトをわざわざ利用している利点が薄れてしまいます。中身を変更する可能性がある場合は、素直にミュータブルなオブジェクトを利用したほうが、余計な混乱を招かずに済むでしょう。

-------------折りたたみここまで---------------

frozenset型の利点

とはいえ、これはどのようなものでもそうだと思うのですが、完全下位互換の存在であれば、強制的にせよ自然淘汰的にせよ、いずれ消えていくのが世の常です。

裏を返せば、frozenset型が実装されたPython2.4のリリースから20年、未だにしっかり残っているfrozenset型には何かしら強みが存在しているはずだともいえます。

まず、この項の初めに書きましたが、frozenset型最大の特徴はイミュータブルなオブジェクトであるという点です。

イミュータブルであるが故に、set型の要素として格納することが可能です。これはset型には出来ないことです。

s1 = {1, 2, 3, frozenset(["a", "b", "c"])}
print(s1)
# TypeError: unhashable type: 'set'
# s2 = {1, 2, 3, {"a", "b", "c"}}
{1, 2, 3, frozenset({'b', 'c', 'a'})}

また、イミュータブルであるということは、辞書のキーとしても利用できるということになります。

sample_dict = {}
sample_dict[frozenset(["a", "b", "c"])] = 23
print(sample_dict)
{frozenset({'b', 'a', 'c'}): 23}

利用例考察

以下では、これまでに確認した基本性質を踏まえて、set, frozensetそれぞれの利用例を考察してみたいと思います。

set型

  • ミュータブルなオブジェクト
  • 全ての要素がユニークかつハッシュ可能
  • 集合に関連する演算が高速に行える

以上を踏まえると、例えば以下のように、ユーザを集団で取り扱って、傾向を分析したりするのに利用できそうです。

website_A_users = {"Alice", "Bob"}
website_B_users = {"Bob", "Charlie", "David"}
common_users = website_A_users & website_B_users
print(common_users)

自作クラスを中に含むというのもありそうですね。

from collections import defaultdict
from dataclasses import dataclass, field
from typing import Optional
from enum import Enum, auto
from datetime import date

class Website(Enum):
    A = auto()
    B = auto()
    C = auto()

@dataclass
class User:
    name: str
    last_login: Optional[date] = None
    visited_website: list[Website] = field(default_factory=list)

    def __hash__(self):
        return hash((self.name, self.last_login))

    def __eq__(self, other):
        if isinstance(other, User):
            return (self.name, self.last_login) == (other.name, other.last_login)
        return False
    
    def __repr__(self):
        return self.name

    
users = [
    User(name="Alice", last_login=date(2023, 5, 1), visited_website=[Website.A, Website.B]),
    User(name="Bob", last_login=date(2023, 5, 2), visited_website=[Website.A]),
    User(name="Charlie", last_login=date(2023, 5, 3), visited_website=[Website.B, Website.C]),
    User(name="David", last_login=date(2023, 5, 5), visited_website=[Website.A, Website.B, Website.C]),
    User(name="Edward", last_login=date(2023, 3, 4), visited_website=[Website.B])
]


users_by_website = defaultdict(set)
for user in users:
    for website in user.visited_website:
        users_by_website[website].add(user)

a_user = users_by_website[Website.A]
b_user = users_by_website[Website.B]
c_user = users_by_website[Website.C]

print(a_user & b_user)
print(b_user | c_user)
print(a_user & b_user & c_user)
{Alice, David}
{David, Edward, Alice, Charlie}
{David}

ユーザーは常にユニークであり、なおかつ色々な観点から取り扱って、分析等を行いたい集団だと想定すると、set型はよくマッチすると言えそうです。

frozenset型

set型と同じく、ユーザーをまとめて取り扱うのにも向いていそうではあります。たとえば時系列毎の利用者であれば、一度記録されてしまえば変更されることはありません。これらはset型よりもfrozenset型に向いているでしょう。

ただ、似たような例を取り扱うのも何か芸が無いと感じるので、ここでは辞書のキーとして利用してみましょう。何かしらの高コストな計算結果、ここでは仮に最大公約数を求める計算を例としますが、これをキャッシュするクラスを作ってみます。

import math
from functools import reduce

class CachedGCDCalculator:
    def __init__(self):
        self.cache = {}
    
    def _calculate_gcd(self, *numbers):
        frozen_numbers = frozenset(numbers)
        if frozen_numbers in self.cache:
            print("cache hit!")
            return self.cache[frozen_numbers]
        else:
            result = math.gcd(*frozen_numbers)
            self.cache[frozen_numbers] = result
            return result
    
    def __call__(self, *numbers):
        return self._calculate_gcd(*numbers)

c = CachedGCDCalculator()
r1 = c(12345 ** 345, 12345 ** 456, 12345 ** 567)
r2 = c(12345 ** 345, 12345 ** 456, 12345 ** 567)
assert r1 == r2

実際には最近のパソコンであれば、この程度は目にも止まらぬ速さでこなしてくれますが、とりあえずの例として出してみました。その他、例えばグラフィック周りや3Dモデル処理周り、機械学習などでも使い道があるかもしれません。造形が浅いので、具体例を出せないのが残念です。

その他、何か思いつかれた方は、コメント欄なり記事なりに書いていただけると幸いです。

まとめ

”とりあえずリスト病”は厄介な病です。これは、初心者にPythonを教えることを想定すると、多少わかりやすくなるかもしれません。

プログラムを初めて触る方にPythonを説明する場合、まずは計算やバインドなど、基本的な動作を扱うことになるでしょう。数値や文字列、ブール型の話をして、ifを使った条件分岐に話題を進めます。FizzBuzz問題は良い題材になりそうです。

その後は、多少詳しく計算関連を掘り下げたり、関数の使い方にフォーカスしたりするかもしれません。ここは苦労するでしょう。仮引数と実引数の概念はあまりしっくりこない方も多いでしょうし、可変長や専用引数は混乱しがちです。イミュータブルなものを内部で扱うときの注意点なんかも必要そうです。

さらに、こだわりがある方はクラスの扱いを掘りさげていくかもしれません。隠蔽・継承・ポリモーフィズムなど、オブジェクト指向に重要な要素が色々と詰まっています。ただ、右も左もわからない状態だと、インスタンスという概念を理解するのが大変かもしれません。

しかしいずれにしろ、遠からず繰り返し処理を扱う機会が来ます。言うまでもなく、プログラムの根幹を構成する要素だからです。生徒たちの「これだとちょっと高級な計算機と同じではないか?」という意識を払拭するためにも、絶対に避けては通れないでしょう。(whileを先に教えるべきか、後に教えるべきかは議論の余地があるかもしれませんが)

そこで、繰り返し処理を扱うにあたって、どのデータコンテナを用いるのか?という話になります。Pythonの組み込みデータコンテナは、大抵繰り返し処理可能ですが、シンプルで、扱いに細かい制限がなくデータの追加・変更を自由に行えるのがリストです。選ばれるのは最早必然とさえ言えるかもしれません。

タプルは要素の変更を学べませんし、arrayは制限が厳しい。辞書はそもそもマッピングという概念を理解する必要がある上に、繰り返し処理がメソッドを使うこともあるので、余計なややこしさが生まれる可能性がある。

結果として、多くのPython学習者は、初めのシーケンス型、繰り返し処理の教材としてリストに触れることになります。ともすると、9割以上の学習者がリストを初めに学ぶことになるかもしれませんね。

もちろんその後にタプルなどについても学ぶでしょう。しかしそれでも、やはり初めてのシーケンス型として、我々の記憶にはリストの存在が色濃く刻まれます。いわば、初めて親の顔を見た雛鳥のようなものです。

そうすると、経験値を稼いでいき、扱うコードの規模が大きくなっても、なんとなくリストにしてしまう私のようなプログラマーが出来上がります。初めに述べた通り、リストを使って困ることは少ないため、改める機会もなかなかありません。

しかし、適切なデータコンテナの使い分けは大きな利益に繋がります。パフォーマンスの向上やエラーの回避、拡張性の確保に留まらず、可読性を向上させることも可能です。set型を見れば、要素がユニークかつハッシュ可能なんだなと思いますし、frozenset型を見れば、どこかで要素が変更される心配はないということで、チェックすべきことが一つ減ります。

とはいえ、他の方が書いたものをさらっと読んだ程度で全てを理解できるほど、私は要領が良くありません。"とりあえずリスト病"を根治するためにも、引き続き記事を書いていきたいと思います。

参考サイト・文献

1
2
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
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?