2.10. 집합

집합(set) 은 고유한 항목들의 순서 없는 모음입니다. 이미 존재하는 값을 추가해도 아무 효과가 없으며, 반복할 때 각 값은 정확히 한 번만 나옵니다. 집합은 멤버십과 중복 제거가 중요하고 순서는 중요하지 않을 때 적합한 도구입니다.

2.10.1. 집합 만들기

비어 있지 않은 집합에는 중괄호를, 빈 집합에는 set() 을 사용하세요:

colours = {"red", "green", "blue"}
empty = set()

중괄호는 dict 리터럴처럼 보입니다. {} 그 자체는 빈 집합이 아니라 빈 딕셔너리 인데, 이는 Python의 역사적 우연 중 하나입니다. 빈 경우에는 set() 을 사용하세요.

set() 은 어떤 이터러블로부터든 집합을 만들기도 하는데, 이는 시퀀스에서 중복을 없애는 표준적인 방법입니다:

nums = [1, 2, 2, 3, 1, 4]
unique = set(nums)
print(unique)

출력:

{1, 2, 3, 4}

출력 순서는 달라질 수 있습니다. 집합은 어떤 특정한 순서로 반복한다고 보장하지 않습니다.

2.10.2. 집합 대 딕셔너리

집합과 딕셔너리는 모두 고유한 항목을 해시 테이블에 저장합니다. 각 항목이 무엇을 함께 지니는가가 차이점입니다:

  • dict키-값 쌍 을 저장합니다. 키를 조회하면 그 값을 반환합니다.

  • set항목만 저장합니다. 항목을 조회하면 그것이 거기 있는지를 알려줍니다.

둘 사이의 선택은 각 항목 옆에 딸린 값 이 의미가 있는지에 관한 것입니다:

  • 각 항목 옆에 어떤 값도 딸려 있지 않을 때, 즉 항목이 존재하는지만 신경 쓰거나 고유 항목들의 그룹을 합집합/교집합으로 결합할 때는 집합 을 선택하세요.

  • 각 항목이 조회로 가져오려는 데이터와 짝지어져 있을 때, 즉 설정 맵, 캐시, 이름으로 키를 매긴 카운터 등에는 딕셔너리 를 선택하세요.

두 타입은 표면적인 구문을 많이 공유하는데, 바로 여기서 대부분의 혼란이 생깁니다. 차이점을 한 블록에 정리하면:

set

dict

담는 것

고유한 항목

고유한 키, 각각 값을 가짐

내용이 있는 리터럴

{1, 2, 3}

{"a": 1, "b": 2}

빈 리터럴

set()

{}

멤버십 검사

x in s

k in d (키만)

값 가져오기

해당 없음

d[k]

항목 추가

s.add(x)

d[k] = v

반복

항목을 내놓음

키를 내놓음 (쌍은 d.items() 사용)

내용이 있는 리터럴과 빈 리터럴 사이의 비대칭성은 짚고 넘어갈 만한 함정입니다:

  • 항목이 들어 있는 중괄호 – {1, 2, 3} – 는 집합 리터럴이고, 키-값 쌍이 들어 있는 중괄호 – {"a": 1} – 는 딕셔너리 리터럴입니다. 파서는 안에 무엇이 있는지로 둘을 구분합니다.

  • 안에 아무것도 없는 중괄호 – {} – 는 빈 집합이 아니라 빈 딕셔너리입니다. 딕셔너리가 먼저 나왔기에 빈 리터럴은 딕셔너리의 것입니다. 빈 집합은 중괄호 리터럴이 아예 없으며 반드시 set() 으로 써야 합니다.

딕셔너리의 키만 읽힐 때 흔히 쓰는 패턴은 집합으로 바꾸는 것입니다. 의도가 분명해지고 쓰이지 않는 값을 메모리에서 덜어냅니다.

2.10.3. 추가와 제거

  • set.add() – 항목 하나를 삽입합니다.

  • set.discard() – 항목이 있으면 제거하고, 없으면 아무것도 하지 않습니다.

  • set.remove() – 항목을 제거합니다. 없으면 KeyError 를 발생시킵니다.

  • set.clear() – 집합을 비웁니다.

s = {1, 2, 3}
s.add(4)
s.discard(99)            # silent: 99 not in s
s.remove(2)
print(s)

출력:

{1, 3, 4}

2.10.4. 멤버십

in 연산자는 멤버십을 검사합니다. 집합에서는 크기와 상관없이 대략 상수 시간이 걸리는데, 이것이 “이 값이 거기 있는가” 만 물어보면 될 때 list 대신 집합을 선택하는 주된 이유입니다:

if "red" in colours:
    print("colour is allowed")

같은 내용을 담은 list 라면 매번 처음부터 훑게 되는데, 항목이 열 개일 때는 괜찮지만 만 개일 때는 느립니다.

2.10.5. 집합 연산

두 집합은 일반적인 수학 연산으로 결합할 수 있습니다. 각각 연산자 형태와 메서드 형태가 모두 있습니다:

  • a | b 또는 a.union(b) – 두 집합 중 어느 쪽에든 있는 모든 것.

  • a & b 또는 a.intersection(b) – 두 집합 모두에 나타나는 것만.

  • a - b 또는 a.difference(b)a 에는 있지만 b 에는 없는 것.

  • a ^ b 또는 a.symmetric_difference(b) – 한쪽에는 있지만 둘 다에는 없는 것.

a = {1, 2, 3, 4}
b = {3, 4, 5, 6}
print(a | b)
print(a & b)
print(a - b)
print(a ^ b)

출력:

{1, 2, 3, 4, 5, 6}
{3, 4}
{1, 2}
{1, 2, 5, 6}

연산자 형태는 읽기 전용이고, 메서드 형태는 오른쪽에 다른 집합뿐 아니라 어떤 이터러블이든 받습니다(a.union([5, 6])). 문맥에서 더 잘 읽히는 쪽을 고르세요.

2.10.6. 집합에 들어갈 수 있는 것

집합 요소는 해시 가능 해야 하는데, 이는 dict 키와 같은 제약입니다. int, float, str, bool, bytes, 그리고 tuple (내용물이 그 자체로 해시 가능할 때) 은 모두 됩니다. listdict 는 안 됩니다. 이를 추가하려 하면 TypeError 가 발생합니다.

2.10.7. frozenset

일반 set 은 가변적입니다. add / remove / discard 를 호출할 때마다 객체가 제자리에서 변합니다. 그 가변성 때문에 집합은 해시 가능 자격을 잃으며, 따라서 집합은 dict 키나 다른 집합의 멤버로 사용할 수 없습니다.

frozenset 은 불변 대응물입니다. set 과 동일한 조회와 연산자(in, |, &, -, ^)를 갖지만, add / remove 가 없고 변경하는 메서드도 없습니다. 내용이 결코 변할 수 없으므로 frozenset 의 해시는 잘 정의됩니다. 따라서 해시 가능 합니다:

primary = frozenset({"red", "green", "blue"})
secondary = frozenset({"yellow", "purple", "orange"})

palettes = {
    primary: "RGB",
    secondary: "mixed",
}

print(palettes[primary])

출력:

RGB

어떤 이터러블로부터든 frozenset을 만드세요. 빈 경우에는 frozenset() 을, 기존 집합의 불변 스냅샷을 취하려면 frozenset(some_set) 을 사용합니다:

snapshot = frozenset(s)         # immutable copy of s
s.add("new")                    # snapshot does not change

이를 사용할 흔한 이유는 두 가지입니다:

  • 딕셔너리 키나 집합 멤버로 사용. 단일 값으로는 필요한 것을 담을 수 없는 어디서든 값들의 frozenset 으로 담을 수 있습니다 – “이 드라이버가 지원하는 기능들의 집합”, “이 프로필이 사용하는 핀들의 집합”.

  • 상수를 잠가두기. 모듈 수준의 허용 이름 frozenset 은 호출자가 실수로 변경할 수 없지만, 일반 set 은 변경할 수 있습니다. 생성 이후 읽기 전용이어야 하는 것에는 frozenset 을 우선하세요.