본 글은 전문가를 위한 파이썬 2판을 읽고 정리한 글입니다.
PyObject와 같은 기본 개념과 C 코드를 대략 읽을 수 있는 독자를 대상으로 합니다.
파이썬 표준 라이브러리는 C로 구현된 시퀀스형 제공합니다. 아래처럼 구분도 가능하고, 가변성에 따른 시퀀스 분류도 가능합니다.
균일 시퀀스가 메모리를 더 적게 사용하지만 , 바이트, 정수, 실수 등 기본적인 자료형만 담을 수 있습니다.
왜 메모리를 적게 사용할까요? 균일 시퀀스는 미리 정해진 타입과 크기를 기반으로 데이터를 저장하며, 일부 타입은 C 스타일로 저장되어 PyObject 형태로 변환되지 않기 때문입니다. 물론 연산 시에는 PyObject로 변환하는 과정이 필요해 추가적인 오버헤드가 발생할 수 있습니다.
PyObject를 잘 모른다면..
https://velog.io/@l_cloud/파이썬과-메모리
set, dict, list comprehension은 for 문에 할당된 변수를 유지하기 위해 고유한 local scope를 할당받습니다. ex) [i for i in range(4)]
’i’ << local scope에 할당
>>> import dis
>>> code = """
... i = 0
... [i for i in range(4)]
... """
>>>
>>> dis.dis(code)
0 0 RESUME 0
2 2 LOAD_CONST 0 (0)
4 STORE_NAME 0 (i)
3 6 LOAD_CONST 1 (<code object <listcomp> at 0x104d363f0, file "<dis>", line 3>)
8 MAKE_FUNCTION 0
10 PUSH_NULL
12 LOAD_NAME 1 (range)
14 LOAD_CONST 2 (4)
16 PRECALL 1
20 CALL 1
30 GET_ITER
32 PRECALL 0
36 CALL 0
46 POP_TOP
48 LOAD_CONST 3 (None)
50 RETURN_VALUE
# list comprehension 내부
Disassembly of <code object <listcomp> at 0x104d363f0, file "<dis>", line 3>:
3 0 RESUME 0
2 BUILD_LIST 0
4 LOAD_FAST 0 (.0)
>> 6 FOR_ITER 4 (to 16)
8 STORE_FAST 1 (i) #외부 변수 i가 아닌 local scope i에 할당
10 LOAD_FAST 1 (i)
12 LIST_APPEND 2
14 JUMP_BACKWARD 5 (to 6)
>> 16 RETURN_VALUE
대괄호 대신 소괄호 사용하면 튜플이 아닌 제너레이터 표현식이 됩니다.
여기서 튜플을 잠시 살펴봅시다. 튜플은 불변 시퀀스의 한 종류입니다. 크기는 고정이며 t가 튜플이고 l이 list일 때, tuple(t)를 하면 list(l) 과 다르게 t에 대한 참조를 반환할 뿐이며 값을 복사할 필요가 없어서 성능상 이점이 있습니다. 참고로 튜플 항목에 대한 참조는 튜플 구조체 배열에 저장되지만, 리스트는 다른 곳에 저장된 참조 배열에 대한 포인터 항목을 가집니다.
// Objects/tupleobject.c
typedef struct {
PyObject_VAR_HEAD
/* ob_item contains space for 'ob_size' elements.
Items must normally not be NULL, except during construction when
the tuple is not yet visible outside the function that builds it. */
PyObject *ob_item[1];
} PyTupleObject;
// Objects/PyListObject.c
typedef struct {
PyObject_VAR_HEAD
/* Vector of pointers to list elements. list[0] is ob_item[0], etc. */
PyObject **ob_item;
/* ob_item contains space for 'allocated' elements. The number
* currently in use is ob_size.
* Invariants:
* 0 <= ob_size <= allocated
* len(list) == ob_size
* ob_item == NULL implies ob_size == allocated == 0
* list.sort() temporarily sets allocated to -1 to detect mutations.
*
* Items must normally not be NULL, except during construction when
* the list is not yet visible outside the function that builds it.
*/
Py_ssize_t allocated;
} PyListObject;
tuple은 최적화 때문에 reverse() 메서드가 제공되지 않습니다. 하지만 reverse()함수 사용이 가능한데 이 이유는 아래와 같습니다.
__reversed__
를 추가하는 것보다 이미 최적화된 __len__
과 __getitem__
을 사용하는 것이 메모리와 성능 면에서 더 효율적이기 때문입니다.개인적인 추측이지만 tuple의 경우 immutable 객체이기 때문에 크기가 고정 + 딱 크기만큼의 배열을 가지고 있어 굳이 __reverse()__
가 필요 없지만, list는 mutable 객체로, 실제 저장된 요소의 개수보다 더 큰 내부 배열 공간을 가지고 있어 len과 getitem만 사용하기에는 비효율적이어서 그렇다고 생각합니다. 자세한 것은 실제 구현 함수를 보면 되겠지요 :)
파이썬에서는 아래처럼 병렬 할당이 가능합니다.
x, y = 1, 2 # 튜플 언패킹 (콤마(,)로 구분된 여러 값들은 자동으로 튜플로 패킹)
a, b, c = [4, 5, 6] # 리스트 언패킹
first, *rest = range(5) # 확장 언패킹
패킹은 여러 개의 값을 하나의 시퀀스로 묶는 것을 의미하고 언패킹은 시퀀스를 개별값들로 푸는 것을 의미합니다. 이는 다중값 반환을 가능하게 합니다.
# 여러 값들이 하나의 튜플로 패킹됨
packed = 1, 2, 3# (1, 2, 3)
# 튜플의 값들이 개별 변수로 언패킹됨
x, y, z = (1, 2, 3)
def get_point(): #다중값 반환 함수
return 1, 2
Go와 Java를 개인적으로 공부한 적이 있는데 해당 언어들이 생각나는 파트였습니다. 단일 값 반환 언어와 다중값 반환 언어 모두 장단이 있지만 개인적으로 저는 다중값 반환을 잘 사용하지는 않습니다. 약타입 + 다중값 반환의 조합에서 유지보수를 잘할 수 있는 코드를 작성할 자신이.. 잘 없습니다 ㅎㅎ
또한 파이썬은 중첩 언패킹도 지원합니다. 언패킹할 표현식을 받는 튜플은 (a,b,(c,d))처럼 다른 튜플을 내포할 수 있고, 이 중첩 구조체에 일치하면 파이썬이 제대로 처리합니다.
ex) [(1,2,(3,4)] → for 문에서 name, _ , (c,d) 이렇게 처리 가능.
파이썬 3.10부터 사용 가능한 문법입니다.
파이썬에서 패턴 매칭이 있다는 것을 이 책을 통해 알게 되었습니다. 조금은 부끄럽네요. match/case 문은 구조 부해를 하고 첫 번째 match되는 case를 실행합니다. (match 이후 break이 없어도 일치하는 case를 실행하지 않습니다) 귀도 반 로섬의 예시도 있습니다.
시퀀스 패턴은 튜플이나 리스트나 어떠한 형태로 중첩한 조합이더라도 차이가 없습니다. collections.abc.Sequence의 구상 혹은 가상 서브 클래스의 객체에 매칭될 수 있으나 str, bytes, bytearray 객체는 시퀀스로 처리되지 않습니다.
# match에서 [ ( << 동일 취급. 리스트나 튜플이나.. (퀀스 패턴은 튜플이나 리스트나 어떠한 형태로 중첩한 조합이더라도 차이가 없음)
def check(value):
match value:
case [x, (y, z)]:
print(f"리스트-튜플: {x}, {y}, {z}")
case [x, int(y)]:
print(f"리스트-정수: {x}, {y}")
case (x,y):
print(f"튜플: {x}, {y}")
case _:
print("매칭 실패")
check([1, (2,3)])
check([1, 2])
check([1, [2,3,3]]) #case (x,y)에 걸림! 유의!
case 안에서 캡처된 변수는 match 문 밖에서도 사용할 수 있습니다. 이는 바다코끼리 연산자(:=)와 같은 스코프 규칙을 따르기 때문입니다.
def check(value):
match value:
case [x,*_ ,[y, z]]:
print(f"매칭: {x}, {y}, {z}")
check([1,2,[45],[5],[3,5,6] ,[4,5]])
*_ ㅇ변수에 바인딩하지 않고 임의 개수의 항목에 매칭 0개 이상의 항목!
Summary: the name becomes a local variable in the closest containing function scope unless there’s an applicable nonlocal or global statement.
“패턴 매칭은 선언적 프로그래밍의 예로서, 어떻게 매칭할지가 아니라 무엇을 매칭할지를 코딩한다.” 라고 저자는 이야기합니다. 개인적으로 강타입 처럼(?) 사용자들이 규칙을 잘 정해두고 사용하면 if elif 보다 훨씬 깔끔하고 명확하게 코딩할 수 있을 것 같습니다.
파이썬 3.9에서 기존 LL(1) 기반 파서에서 PEG 기반 파서로 변경이 있으면서 탄생할 수 있었던 문법입니다. 파서에 관심이 있으시다면 블로그와 PEP 문서를 추천합니다.
seq[start:stop:step] → seq.getitem(slice([start,stop,step])) 호출
memoryview(이후 등장)를 제외한 파이썬 내장 시퀀스형은 1차원 구조이므로, 단 하나의 인덱스나 슬라이스만 지원합니다. 파이썬의 list 객체 내부 구조를 떠올리며 [[1,2],[2,3]] 이런 리스트의 리스트 형태를 생각해 봅시다. 리스트에 리스트 포인터가 저장된 1차원 형태임 것을 상상할 수 있습니다. 즉 데이터가 연속된 메모리 블록에 저장되어 있지 않습니다. 이와 다르게 넘파이는 데이터가 하나의 연속된 메모리 블록에 저장됩니다. shape를 가지고 있어서 진짜 다차원 배열이며 다차원 슬라이싱이 가능합니다.
memoryview
객체는 파이썬 코드가 버퍼 프로토콜 을 지원하는 객체의 내부 데이터에 복사 없이 접근할 수 있게 합니다. (버퍼 프로토콜 == Certain objects available in Python wrap access to an underlying memory array or buffer)
https://docs.python.org/3/c-api/buffer.html#buffer-protocol
공유 메모리 시퀀스 형으로 bytes 형을 복사하지 않고 배열의 슬라이스를 다루게 해 줍니다. 즉 값 복사 없이 사용할 수 있습니다.
# 큰 바이트 시퀀스 생성
data = b'Hello World' * 1000 # 큰 데이터
# 일반적인 슬라이싱 - 새로운 메모리에 복사됨
slice1 = data[1:5]
# memoryview 사용 - 메모리 복사 없이 참조
mv = memoryview(data)
slice2 = mv[1:5] # 복사 없이 원본 메모리 참조
print(bytes(slice2)) # b'ello'
memoryview.cast() 함수는 memoryview가 바라보는 메모리의 내용을 다른 데이터 타입으로 읽는 memoryview 객체를 반환합니다. 물론 언제나 동일한 메모리를 공유합니다.또한 shape() 함수도 있어 다른 형태(2 x 3 - > 3 x 2 등)으로 볼 수 있습니다. 다만 numpy처럼 다차원 슬라이싱을 지원하지는 않습니다.
복합 할당은 원자적 연산이 아닙니다. 아래 예시가 상당히 흥미로웠습니다.
t[2]인 list에 + 연산을 하고 이후 immutable인 t에 = 할당을 하려고해서 에러가 발생하는 예시입니다. 마치 버그인데 기능이라고 우기는 것 같은.. 상황
>>> t = (1,2,[30,40])
>>> t[2] += [50,60]
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment
>>> t
(1, 2, [30, 40, 50, 60])
>>>
리스트가 답이 아닐 때
리스트의 내부 구조를 보면 알 수 있지만 이중 포인터에 모든 요소는 PyObject 요소이며 메모리에 연속적으로 직접 저장되어 있지 않습니다. int만 저장하였다고 해도 PyObject_HEAD를 포함합니다. 이와 다르게 arrayobject는 데이터를 메모리에 연속적으로 직접 저장하며 PyObject형태가 아닌 C 데이터 타입을 저장합니다.
typedef struct arrayobject {
PyObject_VAR_HEAD
char *ob_item;
Py_ssize_t allocated;
const struct arraydescr *ob_descr; // 배열의 타입 정보를 담고 있는 descriptor
PyObject *weakreflist; /* List of weak references */
Py_ssize_t ob_exports; /* Number of exported buffers */
} arrayobject;
물론 여러 타입을 저장할 수 없고 연산시 PyObject로 타입 캐스팅이 필요하지만, 직렬화/역 직렬화 속도도 빠르고 메모리 사용량도 적기 때문에 적절한 상황에 사용하면 좋은 구조체입니다.
기타 자료구조
Queue → thread-safe. SimpleQueue (task_done(), join() 이 없음)
주의! maxsize로 크기를 제한할 수 있지만 deque와 달리 공간이 꽉 찼을 때 항목을 안 버리고, 다른 스레드에서 큐 안의 항목을 제거해 공간을 확보해 줄 때까지 새로운 항목의 추가를 블로킹하며 기다립니다.
긴 글 읽어주셔서 감사합니다 :)