모던 파이썬 기초 #4 컬렉션과 컴프리헨션
#3 제어 흐름에서 마지막에 살짝 본 컴프리헨션을 본격적으로 다룰 차례입니다. 그 전에 파이썬의 네 가지 핵심 컬렉션 — list, tuple, dict, set의 쓰임새부터 정리합니다.
한 표로 보는 비교 #
| 변경 가능 | 순서 | 중복 | 키-값 | 표기 | |
|---|---|---|---|---|---|
list | O | O | 허용 | X | [1, 2, 3] |
tuple | X | O | 허용 | X | (1, 2, 3) |
set | O | X | 불허 | X | {1, 2, 3} |
dict | O | O¹ | 키 unique | O | {"a": 1} |
¹ Python 3.7+ 부터 dict는 삽입 순서를 보장합니다.
list — 가장 많이 쓰는 컬렉션
#
nums: list[int] = [1, 2, 3]
nums.append(4) # [1, 2, 3, 4]
nums.insert(0, 0) # [0, 1, 2, 3, 4]
nums.remove(2) # [0, 1, 3, 4]
last = nums.pop() # 4 반환, 리스트는 [0, 1, 3]
print(len(nums)) # 3
print(nums[0]) # 0
print(3 in nums) # True슬라이스 — [start:stop:step]
#
리스트의 진가는 슬라이스에 있습니다.
items = [10, 20, 30, 40, 50]
items[1:3] # [20, 30] 1 이상 3 미만
items[:2] # [10, 20] 처음부터 2 미만
items[3:] # [40, 50] 3 이상부터 끝까지
items[-2:] # [40, 50] 끝에서 2개
items[::2] # [10, 30, 50] step 2
items[::-1] # [50, 40, 30, 20, 10] 뒤집기step = -1로 리스트를 뒤집는 패턴은 자주 등장합니다. JavaScript의 arr.toReversed()보다 짧고 빠릅니다.
슬라이스는 새 리스트를 만들어 반환 합니다. 원본은 안 바뀝니다.
+와 * — 결합과 반복
#
[1, 2] + [3, 4] # [1, 2, 3, 4]
[0] * 5 # [0, 0, 0, 0, 0][0] * 5는 0으로 채운 리스트를 만드는 가장 짧은 방법입니다. 다만 참조 타입을 곱하면 모두 같은 객체가 됩니다.
matrix = [[]] * 3
matrix[0].append(1)
print(matrix)
# [[1], [1], [1]] ← 모두 같은 리스트를 가리킴!이런 경우는 컴프리헨션을 써야 합니다 (아래에서 다룸).
tuple — 모양이 정해진 묶음
#
list와 거의 같은데 변경 불가입니다. 그 대신 쓰임새가 다릅니다.
point: tuple[float, float] = (1.0, 2.0)
person: tuple[str, int] = ("커티스", 30)
# tuple은 보통 unpack으로 풀어서 씀
name, age = person
print(name, age) # 커티스 30
# 한 원소 tuple은 콤마가 필수 — 괄호만으론 부족
single = (42,) # tuple
not_tuple = (42) # 그냥 정수tuple이 어울리는 경우 #
- 여러 값을 함께 반환할 때:
return name, age(사실은 tuple) - 딕셔너리 키: list는 키로 못 쓰지만 tuple은 됨 (불변이라)
- 모양이 정해진 좌표/날짜 같은 데이터 — list보다 의도가 분명
더 명시적인 tuple — NamedTuple
#
위치가 정해진 tuple은 시간이 지나면 헷갈립니다 (person[0]이 이름이었던가?). 이름을 붙인 tuple을 쓰면 깔끔합니다.
from typing import NamedTuple
class Person(NamedTuple):
name: str
age: int
p = Person("커티스", 30)
print(p.name, p.age) # 커티스 30
# tuple처럼 unpack도 됨
name, age = pdict — 키-값 매핑
#
user: dict[str, int] = {"id": 1, "age": 30}
print(user["id"]) # 1
print(user.get("nope")) # None (없는 키 → 안전)
print(user.get("nope", -1)) # -1 (기본값)
user["name"] = "curtis" # 추가/수정
del user["age"] # 삭제
print("name" in user) # Trueuser["없는키"]는 KeyError 예외를 던집니다. 안전하게 꺼내려면 .get()을 쓰세요.
순회 #
for key in user: # 키만
print(key)
for key, value in user.items(): # 둘 다
print(key, value)
for value in user.values(): # 값만
print(value).items()는 거의 매일 씁니다.
합치기 — | 연산자 (3.9+)
#
defaults = {"a": 1, "b": 2}
overrides = {"b": 20, "c": 30}
merged = defaults | overrides
print(merged) # {"a": 1, "b": 20, "c": 30}|는 오른쪽이 이김 — 같은 키면 뒤쪽 값이 살아남습니다. JavaScript의 {...defaults, ...overrides}와 같은 의미.
set — 중복 없는 묶음
#
unique: set[int] = {1, 2, 3, 2, 1}
print(unique) # {1, 2, 3}
unique.add(4) # {1, 2, 3, 4}
unique.discard(2) # {1, 3, 4}
print(3 in unique) # True
# 빈 set은 set() — {} 는 빈 dict
empty = set()집합 연산 #
a = {1, 2, 3, 4}
b = {3, 4, 5, 6}
a | b # 합집합 {1, 2, 3, 4, 5, 6}
a & b # 교집합 {3, 4}
a - b # 차집합 {1, 2}
a ^ b # 대칭차 {1, 2, 5, 6}리스트 중복 제거의 가장 짧은 방법: list(set(items)). 다만 순서가 깨질 수 있습니다. 순서를 유지하면서 중복 제거하려면:
items = [1, 2, 1, 3, 2, 4]
unique = list(dict.fromkeys(items))
print(unique) # [1, 2, 3, 4]dict.fromkeys()가 dict의 순서 보존 + 키 unique 성질을 동시에 활용합니다.
컴프리헨션 — 한 줄로 컬렉션 만들기 #
for 루프와 조건을 한 줄에 합치는 문법입니다. 파이썬에서 가장 자주 쓰는 패턴 중 하나 입니다.
리스트 컴프리헨션 #
# [표현식 for 변수 in이터러블]
squares = [x ** 2 for x in range(5)]
# [0, 1, 4, 9, 16]for 루프로 풀어 쓰면:
squares = []
for x in range(5):
squares.append(x ** 2)다섯 줄짜리가 한 줄로 줄어들고, 한 번에 의도가 보여 읽기도 더 쉬워집니다.
조건 — 필터 #
evens = [x for x in range(10) if x % 2 == 0]
# [0, 2, 4, 6, 8]if는 필터입니다. 조건에 맞는 원소만 들어갑니다.
변환 + 필터 동시 #
# 짝수만 골라서 제곱
result = [x ** 2 for x in range(10) if x % 2 == 0]
# [0, 4, 16, 36, 64]if-else 표현식 (위치에 주의)
#
if-else는 표현식 위치에 들어갑니다. 필터의 if와 위치가 다릅니다.
labels = ["even" if x % 2 == 0 else "odd" for x in range(5)]
# ['even', 'odd', 'even', 'odd', 'even']순서는 [표현식 if cond else 다른표현식 for x in iter]. 헷갈리는 부분이니 천천히 읽으세요.
중첩 — 2D 만들기 #
matrix = [[0 for _ in range(3)] for _ in range(3)]
# [[0, 0, 0], [0, 0, 0], [0, 0, 0]]앞에서 본 [[0]] * 3의 함정을 컴프리헨션이 해결합니다. 매번 새 리스트가 만들어지니 서로 독립입니다.
딕셔너리 컴프리헨션 #
square_map = {x: x ** 2 for x in range(5)}
# {0: 0, 1: 1, 2: 4, 3: 9, 4: 16}
# 키-값 swap
user = {"id": 1, "name": "curtis"}
swapped = {v: k for k, v in user.items()}
# {1: "id", "curtis": "name"}세트 컴프리헨션 #
unique_lengths = {len(w) for w in ["a", "bb", "cc", "ddd"]}
# {1, 2, 3}함정 — 중첩 for의 순서 #
pairs = [(x, y) for x in [1, 2] for y in ['a', 'b']]
# [(1, 'a'), (1, 'b'), (2, 'a'), (2, 'b')]여러 for가 있을 때 왼쪽이 바깥 루프입니다. 풀어 쓰면:
pairs = []
for x in [1, 2]:
for y in ['a', 'b']:
pairs.append((x, y))제너레이터 표현식 — 메모리를 아낀다 #
대괄호 대신 소괄호를 쓰면 컴프리헨션이 아니라 제너레이터가 됩니다.
gen = (x ** 2 for x in range(1_000_000))
print(gen) # <generator object ...>리스트 컴프리헨션은 즉시 모든 원소를 만듭니다. 100만 개면 100만 개분의 메모리를 잡습니다. 제너레이터는 요청할 때만 다음 값을 만들기 때문에 메모리가 거의 안 듭니다.
total = sum(x ** 2 for x in range(1_000_000))
# 함수에 바로 넣으면 ()를 생략할 수 있음sum, max, any, all같이 한 번 순회만 하면 되는 함수에 넘길 때가 제너레이터의 적합한 용도입니다.
| 리스트 컴프리헨션 | 제너레이터 식 | |
|---|---|---|
| 표기 | [ ... ] | ( ... ) |
| 메모리 | 모든 원소 즉시 생성 | 필요할 때만 |
| 재사용 | 여러 번 순회 가능 | 한 번만 순회 가능 |
| 인덱스 접근 | O result[3] | X |
언제 컴프리헨션, 언제 풀어쓰기? #
컴프리헨션이 항상 좋은 건 아닙니다. 로직이 복잡해지면 풀어쓰는 게 더 읽기 좋습니다.
# 한 줄로 가능은 한데, 읽기 어려움
result = [transform(x) for x in items if validate(x) and is_active(x) and not is_deleted(x)]
# 풀어 쓰면 디버그하기 좋고 읽기도 좋음
result = []
for x in items:
if not validate(x):
continue
if not is_active(x) or is_deleted(x):
continue
result.append(transform(x))판단 기준은: 한 줄에 한 표현식만, if는 한 개까지 정도가 컴프리헨션이 빛나는 범위입니다. 그 이상은 풀어쓰는 게 보통 낫습니다.
정리 #
이번 글에서 정리한 것:
list(가변, 순서, 중복) — 슬라이스[start:stop:step]가 강력tuple(불변, 모양 고정) — 다중 반환,dict 키,NamedTupledict(키-값, 순서 보장) —.get(),.items(),|합치기set(중복 없음, 순서 없음) — 집합 연산| & - ^- 리스트 / 딕셔너리 / 세트 컴프리헨션 —
[표현 for x in iter if cond] if-else는 표현식 위치,if는 필터 위치- 제너레이터 식
(x for x in iter)— 메모리 효율, 한 번 순회 - 너무 복잡하면 컴프리헨션을 풀어쓰는 쪽이 낫습니다
다음 글(#5 함수 — 인자 패턴)에서는 함수 정의의 다양한 인자 패턴을 다룹니다. positional-only, keyword-only, *args / **kwargs 같은 표기까지 짚습니다.