반응형

파이썬에서 shallow copy와 deep copy는 객체를 복제하는 방법들이지만, nested 객체의 복제를 처리하는 방식에서 차이가 있다.

 

일단, Immutable 객체에게 shallow copy와 deep copy를 따질 필요는 없다.

Immutable 객체(숫자, string, tuple 등)의 경우 copy의 종류를 따지는 것은 무의미하다.

왜냐하면 immutable 객체는 생성 후 변경이 불가능하기 때문이다. Immutable 객체 그 자체는 절대 변경될 수 없다. 따라서 같은 immutable 객체를 참조하는 곳 중 한 군데에서 immutable의 객체 값을 수정해서 다른 쪽에 영향을 줄 수 있는 상황 자체가 없는 것이다. 예를 들어 만약 immutable 객체를 담은 변수에 새로운 immutable 객체를 할당할 경우, 변수에는 그 새로운 객체에 대한 reference가 새로 저장될 뿐이다. 

 

참고로, immutable 객체를 copy하면, 파이썬은 메모리 사용의 최적화를 위해, 새로운 메모리 할당을 하지 않고 동일한 immutable 객체에 대한 reference를 복사한다. Immutable 객체는 어차피 수정될 일이 없으므로, 모든 복사된 곳에서 동일한 reference를 갖도록 하는 것이다.

이를 immutable 객체인 tuple과 Shallow copy와 deep copy를 지원하는 파이썬 모듈인 copy를 통해 확인해보자.

import copy

# Immutable 객체 (tuple)
immutable_obj = (1, 2, 3)

# Shallow copy
shallow_copy = copy.copy(immutable_obj)

# Deep copy
deep_copy = copy.deepcopy(immutable_obj)

# 동일한 객체인지 확인
print(immutable_obj is shallow_copy)
print(immutable_obj is deep_copy)
True
True

Shallow copy이든 deep copy이든 상관없이, 원래의 immutable 객체에 대한 reference를 가지고 있음을 확인할 수 있다.

 

다만 원본 immutable 객체 안에 list 같은 mutable 객체가 있으면, deep copy의 경우 새로운 메모리 할당이 발생한다. 

import copy

# Mutable element(list)를 가진 원본 tuple
original = ([1, 2], [3, 4])

shallow = copy.copy(original)
print(original is shallow)

deep = copy.deepcopy(original)
print(original is deep)

deep[0][0] = 99

print("Original:", original)
print("Shallow Copy:", shallow)
print("Deep Copy:", deep)
True
False
Original: ([1, 2], [3, 4])    
Shallow Copy: ([1, 2], [3, 4])
Deep Copy: ([99, 2], [3, 4])

애초에 위처럼 tuple 안에 list를 넣는 코드는 tuple의 불변성과 list의 가변성이 혼합되어 버린다는 점에서 피하는 것이 좋다.

 

Mutable 객체에 대해서는 shallow copy와 deep copy를 따져봐야 한다.

List나 dict와 같은 mutable 객체들에 있어서 shallow copy와 deep copy는 매우 큰 차이가 있다.

왜냐하면 nested 객체를 포함하는 mutable 객체를 복제하는 경우, 한쪽의 수정은 다른 쪽에 영향을 줄 수 있기 때문이다.

 

Python에서 "Shallow Copy"란?

  • Shallow copy는 새로운 collection 객체(list, dict, set 등)가 생성되고, 기존 객체의 자식 객체들에 대한 reference들이 그 collection 객체를 채우는 것을 의미한다.
  • Shallow copy의 순서는 다음과 같다:
    • 먼저 Top-level 컨테이너(새로운 list, 새로운 dict 등)이 생성된다.
    • 생성된 Top-level 컨테이너 안의 element들은 기존 객체의 자식 객체이 복제 생성된 것이 아니라, 기존 객체의 자식 객체들에 대한 reference들이 복제된 것이다.
  • Nested 객체들(예를 들면 list 안의 list)는 복제되지 않는다. 대신 해당 nested 객체들에 대한 reference가 복제된다.
    • 따라서 shallow copy는 "one-level deep"이다. 최상위 객체만이 기존 객체와 독립된 메모리 공간에 위치한 별개의 객체이다.
    • Copy 과정은 recursive하지 않으므로 기존 객체의 자식 객체들이 복제 생성되는 일은 없다.

 

Python에서 "Deep Copy"란?

  • Deep copy는 새로운  collection 객체(list, tuple, dict, set 등)가 생성되고,  copy 과정을 recursive하게 수행한다. 
  • Deep copy의 순서는 다음과 같다:
    • 먼저 Top-level 컨테이너(새로운 list, 새로운 tuple, 또는 새로운 dict 등)이 생성된다.
    • 생성된 Top-level 컨테이어를 기존 객체의 자식객체들을 완전히 새로 복제한 것(clone)으로 recursive하게 채운다.
    • 이렇게 하면 전체 객체 tree를 순회하면서 기존 객체와 그 자식 객체들로부터 완전히 독립된 clone을 생성하게 된다.

 

 

다중 List의 copy 사례를 통해 shallow copy와 deep copy의 원리를 더 이해해 보자.

 

아래 코드를 보자.

sample = [10, 20]

l = [1, 2, 3, sample, 4, 5, 6]
ll = [1, 2, 3, sample, 4, 5, 6]

목표는 다중 list인 lll의 element 중 하나로 sample을 초기 저장하되, 이후에는 각자가 자신의 sample list를 별도로 관리할 수 있도록 하는 것이다.

핵심은 l의 sample 과 ll의 sample이 완전히 별도로 분리되어서, 한쪽을 수정할 때 다른 쪽이 영향받지 않도록 하는 것이다.

 

시도 1. List의 copy() 메소드 사용

List의 내장 함수인 copy()를 사용해본다.

sample = [10, 20]

l = [1, 2, 3, sample, 4, 5, 6]
ll = l.copy()

sample[0] = 1000
print(l, ll)
[1, 2, 3, [1000, 20], 4, 5, 6] [1, 2, 3, [1000, 20], 4, 5, 6]

sample에 대한 수정이 양쪽 list 모두에 영향을 끼치고 있으므로, 복사가 의도한대로 동작하지 않았다.

원인은 list 내장함수인 copy는 shallow copy만을 지원하기 때문이다.

List lll의 주소를 살펴보면 다른 것을 확인할 수 있다. 즉 최상위 객체인 바깥 list는 복제 생성이 됬음을 알 수 있다.

print(hex(id(l)), hex(id(ll)))
0x16e25236800 0x16e25242980 # 양쪽이 다름

반면 lll의 각 element의 주소를 index별로 비교해보면, 모두 양쪽 주소가 동일함을 볼 수 있다. 

즉 list의 자식 객체들에 대한 reference가 그대로 복사되었고, 자식 객체들의 clone이 복제 생성된 것은 아님을 알 수 있다.

for i in range(len(l)):
    print(hex(id(l[i])), hex(id(ll[i])))
0x7ffebe0f4328 0x7ffebe0f4328 # 양쪽이 동일
0x7ffebe0f4348 0x7ffebe0f4348 # 양쪽이 동일
0x7ffebe0f4368 0x7ffebe0f4368 # 양쪽이 동일
0x16e252432c0 0x16e252432c0   # 양쪽이 동일
0x7ffebe0f4388 0x7ffebe0f4388 # 양쪽이 동일
0x7ffebe0f43a8 0x7ffebe0f43a8 # 양쪽이 동일
0x7ffebe0f43c8 0x7ffebe0f43c8 # 양쪽이 동일

 

코드 실행 후 메모리 상태를 그림으로 살펴보자. (pythontutor.com 사용)

lll이 별도의 list 객체로 메모리에 존재하고 있다.

l.copy()는 l의 shallow copy를 생성한다.

  • 새로운 list 객체가 생성된다.
  • l의 element들이 새로운 list 객체의 element로 복사된다(값이 아닌 reference의 복사).
    • immutable 객체(int, str, tuple 등)은 값이 변하지 않으므로 이것이 문제가 안된다.
    • 반면 mutable 객체(list, dict, object 등)은 문제가 된다. Reference가 그대로 복사되므로 mutable 객체의 메모리 공간을 reference가 그대로 가리키고 있기 때문이다. Mutable 객체는 내부 값이 변할 수 있으므로 한쪽 reference를 통해 수정된 값을 다른 reference를 통해 그대로 접근 가능하다.

 

 

시도 2. 내부 list에 copy() 메소드 사용

List l에 미리 sample의 복사본을 생성해두는 방법을 써본다.

sample = [10, 20]

l = [1, 2, 3, sample.copy(), 4, 5, 6]
ll = l.copy()

sample[0] = 1000
l[3][0] = 10000
print(l, ll)
[1, 2, 3, [10000, 20], 4, 5, 6] [1, 2, 3, [10000, 20], 4, 5, 6]

시도 1에서의 방식대로 sample에 대한 수정을 가했을 때 양쪽 list 모두 영향을 받지 않았음을 알 수 있다.

그러나 l[3][0] = 10000 문이 lll 양쪽의 내부 list에 동일하게 수정을 가한 것을 통해, 여전히 lll은 동일한 list 객체를 공유하고 있음을 알 수 있다.

원인은 이번에도  shallow copy만이 수행되었기 때문이다.

List l ll의 주소를 살펴보면 다른 것을 확인할 수 있다. 즉 최상위 객체인 바깥 list는 복제 생성이 됬음을 알 수 있다.

print(hex(id(l)), hex(id(ll)))
0x261e4722f40 0x261e4722a40 # 양쪽이 다름

 lll의 각 element의 주소를 index별로 비교해보면, 모두 양쪽 주소가 동일함을 볼 수 있다. 

즉 list의 자식 객체들에 대한 reference가 그대로 복사되었고, 자식 객체들의 clone이 복제 생성된 것은 아님을 알 수 있다.

for i in range(len(l)):
    print(hex(id(l[i])), hex(id(ll[i])))
0x7ffebe0f4328 0x7ffebe0f4328 # 양쪽이 동일
0x7ffebe0f4348 0x7ffebe0f4348 # 양쪽이 동일
0x7ffebe0f4368 0x7ffebe0f4368 # 양쪽이 동일
0x261e4716800 0x261e4716800   # 양쪽이 동일
0x7ffebe0f4388 0x7ffebe0f4388 # 양쪽이 동일
0x7ffebe0f43a8 0x7ffebe0f43a8 # 양쪽이 동일
0x7ffebe0f43c8 0x7ffebe0f43c8 # 양쪽이 동일

 

코드 실행 후 메모리 상태를 그림으로 살펴보자. (pythontutor.com 사용)

l의 내부 list는 sample로부터 shallow copy된 객체이고, lll이 shallow copy된 객체이다.

그러므로 sample과 l ll 의 내부 list는 완전히 분리된 객체이고, lll 이 별도의 list 객체로 메모리에 존재하고 있지만, lll의 내부 list는 여전히 동일한 list를 reference하고 있는 것을 알 수 있다.

 

시도 3.  copy 모듈의 deepcopy() 사용

copy 모듈을 import해서 deepcopy() 함수를 사용해본다.

이제는 l도 (sample을 내부 list로 갖는) 임의의 list에 대해 deepcopy() 함수를 호출한 객체이다. lll에 대해 deepcopy() 함수를 호출한 객체이다.

import copy

sample = [10, 20]

l = copy.deepcopy([1, 2, 3, sample, 4, 5, 6])
ll = copy.deepcopy(l)

sample[0] = 1000
l[3][0] = 10000

print(l, ll)
([1, 2, 3, [10000, 20], 4, 5, 6], [1, 2, 3, [10, 20], 4, 5, 6])

sample에 대한 수정이 l의 내부 list와 ll의 내부 list에 영향을 주지 않았고,  l의 내부 list에 대한 수정이 ll의 내부 list에 영향을 주지 않았음을 확인할 수 있다.

즉 이번에는 다중 list의 deep copy가 수행되었다.

List l ll의 주소를 살펴보면 다른 것을 확인할 수 있다. 최상위 객체인 바깥 리스트는 복제 생성이 됐다는 뜻이고, 이것은 shallow copy 때와 동일하다.

print(hex(id(l)), hex(id(ll)))
0x24245fd6340 0x24245fb45c0

 l ll의 각 element의 주소를 index별로 비교해보면, 이번에는 내부 list의 주소가 다른 것을 볼 수 있다. 

즉 sample과 l의 내부 list와 ll의 내부 list가 모두 별도의 메모리 공간에 위치한다.

(참고로 deep copy가 적용됬음에도 내부 list를 제외한 다른 element들은 주소값이 동일하다. 이는 integer interning 때문이다. 관련 내용은 https://comgu.tistory.com/entry/Python-List-곱하기-연산의-메모리-구조-분석1차원-list-다중-list 글에서 다루었다.)

for i in range(len(l)):
    print(hex(id(l[i])), hex(id(ll[i])))
0x7ffebe0f4328 0x7ffebe0f4328 # 양쪽이 동일
0x7ffebe0f4348 0x7ffebe0f4348 # 양쪽이 동일
0x7ffebe0f4368 0x7ffebe0f4368 # 양쪽이 동일
0x24245fd6f00 0x24245facbc0   # 양쪽이 다름
0x7ffebe0f4388 0x7ffebe0f4388 # 양쪽이 동일
0x7ffebe0f43a8 0x7ffebe0f43a8 # 양쪽이 동일
0x7ffebe0f43c8 0x7ffebe0f43c8 # 양쪽이 동일

 

코드 실행 후 메모리 상태를 그림으로 살펴보자. (pythontutor.com 사용)

 

sample, l, ll이 모두 완벽하게 분리된 메모리 공간에 위치하게 된 것을 확인 가능하다.

Deep copy를 통해서 list의 모든 element들이 recursive하게 복제 생성되었음을 알 수 있다.

 

참고: https://realpython.com/copying-python-objects/

 

Shallow vs Deep Copying of Python Objects – Real Python

What's the difference between a shallow and a deep copy of a Python object? Learn how to clone arbitrary objects in Python, including your own custom classes.

realpython.com

반응형
반응형

파이썬에서 List의 복사에 대한 다양한 케이스를 다루면서, 관련된 파이썬의 메모리 구조에 대해 이해해보자.

파이썬 버전 3.11.7을 기준으로 작성했다.

 

1. 1차원 List 곱하기

아래의  코드로 list 곱하기를 실행해보자.

l = [10, 10] * 3
print(l)
[10, 10, 10, 10, 10, 10]

[10, 10]이 3번 반복되어서 [10, 10, 10, 10, 10, 10]이 생성되었다. 

* 연산자는 [10, 10]의 복사본을 concatenate해서 이어 붙인다.

 

정확히 말하자면,

  1. 정수 10은 immutable 정수 객체이다.
  2. 파이썬은 먼저 [10, 10] 코드를 evaluate한다. 이때 동일한 "10 정수 객체에 대한 reference인 element"가 2개 있는 list [10, 10]이 생성된다.
  3. list 곱하기 연산 수행시 [10, 10] 안의 reference들이 3번 반복된 새로운 list(즉 [10, 10, 10, 10, 10, 10])가 생성된다.
  4. [10, 10, 10, 10, 10, 10]의 각 10은 새로운 객체가 아닌 동일한 10 정수 객체에 대한 reference이다.

 

List l의 모든 10 인스턴스들은 정수 객체 10이 담겨있는 동일한 메모리 위치를 참조한다.

파이썬에서 정수는 immutable 객체이고, 10은 파이썬에 의해 integer interning이 적용된 small integer 객체이다. 

  • Integer Interning: 파이썬은 -5 ~ 256 사이의 작은 정수는 별도의 메모리 공간을 미리 할당해서 저장해놓는다. 자주 사용하는 정수를 이렇게 따로 보관함으로써 메모리 사용을 최적화시키는 것이다.
for i in l:
    print(id(i))
    
print(id(10))
140733156967496 # id(l[0])
140733156967496 # id(l[1])
140733156967496 # id(l[2])
140733156967496 # id(l[3])
140733156967496 # id(l[4])
140733156967496 # id(l[5])

140733156967496 # id(10)

id() 함수를 사용해 객체의 주소를 파악할 수 있다. List l의 모든 아이템들이 동일한 메모리(10 정수 객체가 저장된 공간)를 참조하고 있음을 알 수 있다.

 

Integer interning이 적용되지 않는 정수들의 list에 대해서도 list 곱하기시 아이템들의 주소값을 확인해보자.

l = [1000, 10000] * 3
print(l)
[1000, 10000, 1000, 10000, 1000, 10000]

기존 list의 복사본이 concatenate되는 것은 동일하다.

for i in l:
    print(id(i))
    
print(id(1000))
print(id(10000))
2040756466320 # id(l[0])
2040756466480 # id(l[1])
2040756466320 # id(l[2])
2040756466480 # id(l[3])
2040756466320 # id(l[4])
2040756466480 # id(l[5])

2040754126128 # id(1000)
2040754126128 # id(10000)
  1. 1000인 l[0], l[2], l[4]의 주소값과 id(1000)의 값이 다르고, 10000인 l[1], l[3], l[5]의 주소값과 id(10000)의 값이 다르다.
    • 1000과 10000은 Integer interning이 적용되지 않는 큰 값이기 때문에, 각각 별도의 메모리 공간에 생성된 값을 참조하고 있다.
    • id(1000)이 실행되는 순간, global scope에서 새로운 1000 객체가 생성된다(integer interning의 대상이 아니므로 새로 생성).
  2. 1000인 l[0], l[2], l[4]가 같은 주소값을 갖고, 10000인 l[1], l[3], l[5]가 같은 주소값을 갖는다.
    • 메모리에 1000과 10000 객체가 먼저 생성되어 존재하고, 이에 대한 reference가 2번 복사되어 concatenate된 것이다.
  3. 추가적으로 위 출력결과를 보면 id(1000)과 id(10000)이 동일한 주소값을 갖는 것을 볼 수 있다. 
    • 이것은 list 곱하기 연산과는 상관없는 내용이다.
    • 파이썬의 메모리 사용 최적화로 인한 결과이다.
    • id(1000)이 실행되기는 하지만 메모리 할당 이후 주소 출력 외에 딱히 참조되지 않으므로 파이썬 garbage collector가 해당 메모리를 회수해간다. 이후 id(10000)가 호출될 때 같은 주소의 메모리가 할당되었고, 마찬가지로 주소 출력 외에 딱히 참조되지 않으므로 파이썬 garbage collector가 또다시 해당 메모리를 회수해간다.
    • 즉 integer interning의 대상이 아닌 1000이나 10000의 값은 파이썬에 의해 항시적으로 할당되는 메모리가 존재하지 않으므로, 참조하는 곳이 없으면 곧바로 메모리가 회수되어버리는 것이다.

 

아래처럼 l[2]의 값을 수정해보자.

l[2] = 99999
print(l)
[1000, 10000, 99999, 10000, 1000, 10000]
for i in l:
    print(id(i))
2040756466320 # id(l[0])
2040756466480 # id(l[1])
2040754126128 # id(l[2])
2040756466480 # id(l[3])
2040756466320 # id(l[4])
2040756466480 # id(l[5])

이제 l[2]의 주소값이 l[0] 및 l[4]와 달라진 것을 확인할 수 있다.

l[2] = 99999가 실행될 때, 메모리에 새롭게 99999 정수 객체가 생성되었고, l[2]는 그 객체에 대한 reference를 갖게 되기 때문이다.

 

2. 다중 list 곱하기

아래 코드로 다중 list 곱하기를 실행해보자.

l = [[10, 10] * 3] * 2
l[0][0] = 9999
print(l)
[[9999, 10, 10, 10, 10, 10], [9999, 10, 10, 10, 10, 10]]

예상하는 것과 다르게, l[0][0] 뿐 아니라 l[1][0] 값도 바뀐 것을 확인할 수 있다.

이유를 찾기하기 위해 순서대로 분석해보자.

  1. [10, 10] * 3 실행 시, [10, 10, 10, 10, 10, 10] 가 생성된다.
  2. [[10, 10] * 3] 실행 시, nested list인 [[10, 10, 10, 10, 10, 10]]가 생성된다.
  3. [[10, 10] * 3] * 2 실행 시, [[10, 10, 10, 10, 10, 10], [10, 10, 10, 10, 10, 10]]가 생성된다.
    • 이때 2개의 안쪽 list(즉 l[0]l[1])는 메모리의 동일한 객체를 참조한다. 이 부분이 핵심이다.
  4. l[0][0] = 9999
    • 첫 안쪽 list의 첫 element인 l[0][0]을 수정한다.
    • 2개의 안쪽 list가 동일한 list 객체를 참조하고 있기 때문에 두번째 안쪽 list의 첫 element도 수정된다.

 

pythontutor.com 을 활용해서 위 과정을 그려봤다.

1. l = [[10, 10] * 3] *2 실행 후

list 곱하기 연산이 진행된 상태인데, 2개의 안쪽 list는 동일한 list 객체를 참조하고 있음을 알 수 있다.

2. l[0][0] = 9999 실행 후

2개의 안쪽 list가 동일한 list를 참조하고 있으므로, 한쪽에서 해당 list의 element 값을 수정하면 다른 쪽도 영향을 받음을 알 수 있다.

 

요약

1차원 list이든 다중 list이든 상관없이, list의 각 element는 특정 객체에 대한 reference를 나타낸다(파이썬에서는 변수가 객체를 참조하기 때문이고, list element도 동일하다).

따라서 list에 대한 곱하기 연산이 수행될 때, list의 element인 객체의 값이 복제되는 것이 아니라 reference가 복제되는 것이다.

1. 1차원 list의 곱하기 연산의 경우, list element는 immutable 객체(이 경우 정수)에 대한 reference이며, 곱하기 연산시 해당 immutable 객체에 대한 reference가 복제된다.

[10, 10] * 3

2. 다중 list의 곱하기 연산의 경우, list element는 안쪽 list 객체에 대한 reference이며, 곱하기 연산시 해당 list 객체에 대한 reference가 복제된다.

[[10, 10] * 3] * 2
반응형
반응형

문제를 분석해보면, 아래와 같은 흐름으로 석판 간 이동을 해야 한다:

첫 위치 -> I -> L -> O -> V -> E -> Y -> O -> N -> S -> E -> I

 

위 구간을 2가지로 쪼갤 수 있다

  1. 첫 위치 -> I 구간
    • 거리가 입력에 따라 값이 달라진다.
    • 입력이 'A'였다면, A -> I 이동 거리인 8.
  2. I -> L -> O -> V -> E -> Y -> O -> N -> S -> E -> I  구간
    • 입력에 상관없이 언제나 반드시 이동해야 하는 거리가 정해져있다. 그 값은 84.

 

파이썬에서 문자의 아스키 코드값을 반환해주는 ord() 함수를 사용하면 문자간 차이를 쉽게 구할 수 있다.

 

(정답 코드)

def get_distance(x, y):
    return abs(ord(x) - ord(y))


s = input("").upper()
d = get_distance(s, "I")
p = "ILOVEYONSEI"
for i, c in enumerate(p[:-1]):
    d += get_distance(p[i], p[i + 1])
print(d)

 

https://www.acmicpc.net/problem/25915

 

반응형
반응형

간단한 문자열 처리 문제였다.

여러 line에 걸쳐 입력받은 각 input string마다, 

모음이 나오기 전까지 모든 자음을 임시 string(코드의 no_vowels)에 저장해주고,

모음이 나오면 해당 임시 string을 뒤쪽에 붙인 뒤 "ay"까지 붙여주면 해결된다.

(정답 코드)

vowels_list = ("a", "e", "i", "o", "u")
while True:
    s = input()
    if s == "#":
        break
    no_vowels = ""
    for c in s:
        if c in vowels_list:
            break
        else:
            no_vowels += c
    print(s[len(no_vowels) :] + no_vowels + "ay")

 

https://www.acmicpc.net/problem/9226

 

반응형
반응형

파이썬에서, asterisk(*) 기호는 함수 콜에서 argument를 pack 또는 unpack하거나, list나 dict같은 자료구조를 다룰 때 사용된다.

 

1. Positional argument 패킹

*를 사용해서, 여러 positional argument를 하나의 tuple로 묶을 수 있다.

def pack_args(*args):
    print(args)

pack_args(1, 2, 3, 4) # 출력: (1, 2, 3, 4)

여기서 *args는 전달된 모든 positional argument를 하나의 tuple로 패킹한다.

 

2. Positional argument 언패킹

*는 list나 tuple 같은 iterable 객체를 쪼개서 각 item을 함수의 개별 argument로 전달할 때 사용할 수 있다.

def add(a, b, c):
    return a + b + c

numbers = [1, 2, 3]
result = add(*numbers)
print(result) # 출력: 6

 

3. Keyward argument 패킹

**를 사용하면 여러 keyward argument를 하나의 dict로 묶을 수 있다.

def pack_kwargs(**kwargs):
    print(kwargs)

pack_kwargs(name="Alice", age=25) # 출력: {'name': 'Alice', 'age': 25}

 

4. Keyward argument 언패킹

**는 dict를 함수의 keyward argument로 전달할 때 사용할 수 있다.

def greet(name, age):
    print(f"안녕하세요, {name}! 나이는 {age}살입니다.")

info = {'name': 'Alice', 'age': 25}
greet(**info) # 출력: 안녕하세요, Alice! 나이는 25살입니다.

 

5. 할당에서 언패킹

*는 list나 tuple에서 값을 분리하여 할당하는 데 사용할 수 있다.

할당할 데이터보다 변수의 갯수가 적은 경우, 데이터를 모두 할당할 수 있도록 *를 사용하면 된다.

a, *b, c = [1, 2, 3, 4, 5]
print(a)  # 출력: 1
print(b)  # 출력: [2, 3, 4]
print(c)  # 출력: 5

range, map, filter에서도 사용할 수 있다.

*a, = range(5)
print(a) # 출력: [0, 1, 2, 3, 4]
a, *b = range(5)
print(a, b) # 출력: 0, [1, 2, 3, 4]
*a, b, c = map(lambda x : x * 2, [1, 2, 3, 4, 5])
print(a, b, c) # 출력: [2, 4, 6] 8 10
a, *b, c = filter(lambda x : x > 3, [6, 7, 1, 3, 2, 4, 5])
print(a, b, c) # 출력: 6 [7, 4] 5

 

6. 패킹과 언패킹 혼합

***를 함수 정의와 호출에서 혼합하여 사용할 수 있다.

def mixed_function(a, b, *args, **kwargs):
    print(f"a: {a}, b: {b}, args: {args}, kwargs: {kwargs}")

mixed_function(1, 2, 3, 4, x=5, y=6) # 출력: a: 1, b: 2, args: (3, 4), kwargs: {'x': 5, 'y': 6}

 

7. 데이터 구조에서 언패킹

*는 list, tuple 등 데이터 구조에서 언패킹할 때도 사용할 수 있다.

# list 언패킹
numbers = [1, 2, 3, 4]
new_list = [0, *numbers, 5]
print(new_list) # 출력: [0, 1, 2, 3, 4, 5]

# tuple 언패킹
a, *b, c = (10, 20, 30, 40)
print(b)  # 출력: [20, 30]

 

요약

  • *: 위치 인수를 패킹/언패킹
  • **: 키워드 인수를 패킹/언패킹
  • list, tuple 등의 데이터 구조를 더 유연하게 다룰 수 있게 된다.
반응형
반응형

파이썬에서 static method는 클래스의 인스턴스가 아닌 클래스 자체에 속하는 메소드를 말한다. 

Static method는 @staticmethod 데코레이터를 사용해 정의되며, 주로 인스턴스나 클래스의 상태에 비의존적인 유틸리티 함수를 위한 것이다.

class MyClass:
    @staticmethod
    def my_static_method(arg1, arg2):
        return f"{arg1}, {arg2}"

 

Static method의 핵심 특성

  1. 클래스와 관련된 기능을 위해 사용되지만, 클래스나 인스턴스의 변수들과는 독립적이다.
  2. 클래스의 인스턴스(self)나 클래스(cls)를 첫번째 인자로 받지 않는다.
    • 따라서 클래스 인스턴스의 데이터에 접근할 수 없다(self 객체에 저장된 attribute들에 접근할 수 없음).
    • 따라서 클래스의 attribute들 또는 메소드들에 접근할 수 없다(static method 내에서 클래스명을 명시해서 접근할 수는 있음).
  3. 클래스 자체나 클래스 인스턴스에서 모두 호출할 수 있다.
    • static method는 특정 클래스나 인스턴스에 종속되지 않으므로, 접근하는 방식에 상관없이 동일하게 동작한다.
class MathUtils:
    @staticmethod
    def add(a, b):
        return a + b

    @staticmethod
    def multiply(a, b):
        return a * b

# 클래스에서 static method를 호출
print(MathUtils.add(10, 20))  # 출력: 30
print(MathUtils.multiply(5, 4))  # 출력: 20

# 인스턴스에서 static method를 호출
math_utils = MathUtils()
print(math_utils.add(15, 25))  # 출력: 40

 

Static method는 언제 사용되는가?

  1. 특정 메소드가 selfcls를 사용하지 않을 때 
  2. 클래스에 관련된 유틸리티 또는 helper 함수로써
  3. 클래스와 논리적으로는 관련되있으나 인스턴스의 데이터는 필요로 하지 않는 함수를 구현할 때

 

왜 Static Method라는 걸 설계했을까?

  1. 유틸리티 함수 역할: static method는 클래스와 논리적으로 연관된 기능을 제공하지만, 특정 인스턴스나 클래스 상태와는 무관하게 동작할 때 유용하다. 즉 인스턴스나 클래스 상태와 무관하게 항상 일정하게 동작하기 때문에 "정적(static)" 메소드라고 부르는 것이다.
  2. 코드 가독성: static method를 일반 함수처럼 작성할 수도 있지만, 클래스 안에 정의하면 해당 기능이 클래스와 관련있다는 점을 명확히 보여줄 수 있다.

 

class Calculator:
    @staticmethod
    def add(a, b):
        return a + b

    @staticmethod
    def subtract(a, b):
        return a - b

# 클래스 상태와 무관하게 일정하게 동작
print(Calculator.add(10, 5))       # Output: 15
print(Calculator.subtract(10, 5))  # Output: 5

# 인스턴스를 생성해도 동일
calc = Calculator()
print(calc.add(20, 10))            # Output: 30
print(calc.subtract(20, 10))       # Output: 10

클래스(Calculator)를 통해 호출하든, 인스턴스(calc)를 통해 호출하든 static method의 결과는 항상 동일하다.

반응형
반응형

파이썬 OOP에서 클래스 변수와 인스턴스 변수는 중요한 개념이다. 둘의 개념과 사용시 주의해야 할 점을 소개한다.

 

1. 클래스 변수 (Class Variable)

  • 정의: 클래스 변수는 클래스 자체에 속하는 변수로, 클래스의 모든 인스턴스에서 공유된다.
  • 특징: 클래스 변수를 변경하면 모든 인스턴스에서 그 변경 사항을 공유한다.
  • 사용방법: 클래스 내에서 self가 아닌 클래스명으로 접근할 수 있다.

클래스 변수는 클래스 선언부 아래에 정의된다.

"{클래스명}.{클래스변수}  = 값" 형식으로 클래스 변수를 수정하면, 다른 인스턴스들에서도 그 수정된 값을 보게 된다.

(반면, "{인스턴스명}.{클래스변수} = 값" 형식으로 클래스 변수를 수정하고자 시도하면 다른 인스턴스들에서 그 수정된 값을 볼 수 없다. 이유는 아래 3.2. 인스턴스에서 클래스 변수 덮어쓰기(Shadowing 문제)에서 자세히 다룬다.)

class MyClass:
    class_variable = 0  # 클래스 변수

    def __init__(self, value): # 클래스 생성자
        self.instance_variable = value  # 인스턴스 변수

# 클래스 변수 접근
print(MyClass.class_variable)  # 0

# 인스턴스를 생성하고 인스턴스 변수 접근
obj1 = MyClass(10)
print(obj1.instance_variable)  # 10

# 클래스 변수 수정
obj2 = MyClass(20)
MyClass.class_variable = 5
print(obj1.class_variable)  # 5
print(obj2.class_variable)  # 5

아래는 클래스 변수가 list인 경우이다.

각 인스턴스 객체는 클래스 변수인 list의 주소를 참조하고 있기 때문에, list 주소에 접근 후 마치 클래스 변수인 것처럼 list를 조작할 수 있다.

인스턴스 입장에서 list 클래스 변수를 수정할 때는 "{인스턴스명}.{클래스변수} = 값" 형식을 사용하지 않고 append()를 쓰므로, 다른 인스턴스들에서도 수정된 클래스 변수 값을 확인할 수 있다.

class MyClass:
    shared_list = []  # 클래스 변수

# 인스턴스 생성
a = MyClass()
b = MyClass()

# 인스턴스에서 리스트에 값 추가
a.shared_list.append(1)

# 인스턴스 변수 및 클래스 변수에서 확인
print(a.shared_list)  # [1]
print(b.shared_list)  # [1]
print(MyClass.shared_list)  # [1]

# 클래스에서 리스트에 값 추가
MyClass.shared_list.append(2)

# 인스턴스 변수 및 클래스 변수에서 확인
print(a.shared_list)  # [1, 2]
print(b.shared_list)  # [1, 2]
print(MyClass.shared_list)  # [1, 2]

list, dict, set, 사용자 정의 클래스 객체와 같은 mutable(가변) 객체들을 클래스 변수로 삼을 때는 값을 변경했을 때, 모든 다른 인스턴스들에서도 해당 값을 확인할 수 있다.

mutable과 immutable에 대한 내용은 다음 링크를 참고: https://comgu.tistory.com/entry/Python-Immutable불변-vs-Mutable가변

 

2. 인스턴스 변수 (Instance Variable)

  • 정의: 인스턴스 변수는 객체(인스턴스)별로 개별적으로 존재하는 변수다.
  • 특징: 각 인스턴스마다 고유한 값을 가질 수 있으며, 다른 인스턴스에는 영향을 미치지 않는다.
  • 사용 방법: 인스턴스 변수는 self를 통해 접근한다.
class MyClass:
    def __init__(self, value):
        self.instance_variable = value  # 인스턴스 변수

# 두 개의 인스턴스 생성
obj1 = MyClass(10)
obj2 = MyClass(20)

# 인스턴스 변수 값 확인
print(obj1.instance_variable)  # 10
print(obj2.instance_variable)  # 20

 

3. 주의사항

  1. 클래스 변수에 접근: {클래스명}.{클래스변수} 형식으로 접근하거나, {인스턴스명}.{클래스변수}를 통해 접근할 수 있다. 하지만 일반적으로 클래스 변수는 {클래스명}.{클래스변수} 형식으로 직접 접근/변경하는 것이 좋다.
    • 클래스명으로 접근하면 변수의 소속을 명확히 보여준다는 장점이 있다.
    • 인스턴스를 통해 클래스 변수에 접근하는 것은 가독성을 떨어뜨린다.
      • {클래스명}.{클래스변수} 형식은 클래스 변수라는 것을 직관적으로 의미 파악이 가능하다.
      • 반면  {인스턴스명}.{클래스변수}는 클래스 변수인지 인스턴스 변수인지 구분하기가 어렵다.
  2. 인스턴스에서 클래스 변수 덮어쓰기(Shadowing 문제): 인스턴스에서 클래스 변수와 동일한 이름의 변수를 설정하면, 새로운 인스턴스 변수가 생성된다. 이 경우 클래스 변수는 영향을 받지 않는다.

아래 코드를 보자.

Car 클래스의 brand는 클래스 변수이다. 

그런데 Car 클래스의 인스턴스(car1 또는 car2)에서 change_brand()를 호출하면, 동일한 이름인 brand를 변수명으로 갖는 인스턴스 변수가 새롭게 생성된다.

이때 클래스 변수 brand는 더 이상 해당 인스턴스에서 {인스턴스명}.{클래스변수} 방식으로 접근할 수 없게 된다. 즉 인스턴스 변수 brand가 클래스 변수 brand를 가리므로(shadow하므로), self.brand 는 인스턴스 변수로서만 동작하게 된다.

즉, self.brand = ...와 같이 인스턴스 변수를 명시적으로 설정하면 클래스 변수와의 공유 상태가 끊어진다.

class Car:
    brand = None  # 클래스 변수

    def change_brand(self, brand):
        self.brand = brand  # 인스턴스 변수 생성

# 인스턴스 생성 후 메서드 호출
car1 = Car()
car2 = Car()

car1.change_brand("Toyota")
car2.change_brand("Honda")

print(car1.brand)  # 출력: Toyota -> 신규 생성된 인스턴스 변수 값 출력
print(car2.brand)  # 출력: Honda  -> 신규 생성된 인스턴스 변수 값 출력
print(Car.brand)   # 출력: None   -> 클래스 변수의 값은 불변


파이썬에서는 인스턴스 속성을 참조하려고 할 때 다음 순서로 탐색한다:

  1. 인스턴스의 __dict__에서 해당 속성 이름을 찾는다.
  2. 만약 인스턴스에서 속성을 찾지 못하면 클래스에서 해당 속성을 찾는다.
  3. 클래스에서도 찾지 못하면 상위 클래스(있는 경우)로 탐색한다.

self.brand = brand를 실행하면 brand라는 이름이 인스턴스의 네임스페이스(__dict__)에 추가된다.

이후로는 self.brand를 참조할 때 인스턴스 변수가 우선적으로 반환되어, 이로 인해 클래스 변수 brand는 가려지게 된다.

-> 이것이 Shadowing의 동작 메커니즘이다.

 

따라서.. Good Practice는:

클래스 변수와 인스턴스 변수를 구분하는 것이다. 같은 이름을 사용할 경우 혼란이 생길 수 있다.

클래스 변수와 인스턴스 변수를 명확히 구분하거나, 이름을 다르게 지정해 충돌을 방지하는 것이 좋다.

반응형
반응형

Python에서 iteration(반복)은 iterator 프로토콜을 기반으로 동작한다.

이 원리를 이해하려면 아래 개념들을 알아야 한다.

 

1. Iterable (Iterate 가능한 객체)

Iterable은 일반적으로 __iter__() 메서드를 구현한 객체를 의미한다. for 문 등 반복문에서 iterate할 수 있는 객체를 말한다.

다음과 같은 종류의 iterable이 있다:

  • sequence형 자료구조: list, tuple, dict, str, range, bytes, bytearray 등.
  • 반복가능한 다른 자료구조: dict, set, frozen_set, zip, map, filter 등.
  • iterator와 generator: iterator는 아래에서 다루고, generator는 별도의 글(https://comgu.tistory.com/entry/Python-제너레이터Generator)에서 다룬다.

특징:

  • iter() 함수를 호출하면 iterator 객체를 반환한다. iter()를 호출하는 것은 내부 구현된 __iter__() 메서드를 호출하는 것과 동일하다.
my_list = [1, 2, 3]
iterator = iter(my_list)  # iterator 반환
print(iterator)
<list_iterator object at 0x00000220BEB89C00>

위 코드에서 볼 수 있듯이, list 객체에 대한 iter() 호출은 list 전용 iterator인 list_iterator 객체를 반환한다.

print(iter("abc"))
<str_ascii_iterator object at 0x0000023A9F72A6B0>

str 객체에 대한 iterator는 str_ascii_iterator 타입 객체임을 알 수 있다.

 

일반적인 것은 아니지만, __iter__()를 직접 구현하지 않아도 iterable일 수 있다. 그 방법은 index 0부터 동작하는 __getitem__()을 구현하는 것이다. 이 경우, 파이썬의 iteraion 메커니즘은 __getitem__()을 사용해서 index 0부터 시작해서 IndexError가 발생할 때까지 item을 순서대로 fetch한다. (__iter__() 없이 index 0부터 시작하는 __getitem__()만 구현하는 객체에 대해, 파이썬은 iterator를 자동으로 생성한다)

class OnlyGetItem:
    def __getitem__(self, index):
        if index < 3:  # 3개 item만 가지는 케이스
            return index * 2
        raise IndexError()

# __iter__ 가 구현되지 않아도 아래 코드가 동작한다.
obj = OnlyGetItem()
for x in obj:
    print(x)  # Prints: 0, 2, 4

__getitem__()으로 iterable을 구현하는 방법은 파이썬 초기의 legacy feature이기 때문에 권장되지 않는다.

 

어떤 객체가 iterable인지 확인하는 방법은 아래와 같다.

from collections.abc import Iterable
isinstance(obj, Iterable) # 이 값이 True인지 확인해본다.

 

2. Iterator

Iterator는 iterable 객체에서 값을 하나씩 꺼내오는 객체이다. Iterator는 반드시 __iter__()__next__() 메서드를 가지고 있다.

  • __iter__(): iterator 자신을 반환한다(iterator는 자기 자신이 iterable이기도 하다).  iter()를 호출하는 것은 내부 구현된 __iter__() 메서드를 호출하는 것과 동일하다.
  • __next__(): iterable의 다음 값을 반환한다. 더 이상 반환할 값이 없으면 StopIteration 예외를 발생시킨다. next()를 호출하는 것은 내부 구현된 __next__() 메서드를 호출하는 것과 동일하다.
my_list = [1, 2, 3]
iterator = iter(my_list)

print(next(iterator))  # 1
print(next(iterator))  # 2
print(next(iterator))  # 3
print(next(iterator))  # 더 이상 값이 없으면 StopIteration 발생
1
2
3
Traceback (most recent call last):
  File "C:\work\python_practice\iter.py", line 8, in <module>
    print(next(iterator))
          ^^^^^^^^^^^^^^
StopIteration

 

3. Iterator Protocol

Iterator와 Iterable이 상호작용하는 규칙을 말한다. 이 규칙에 따라 파이썬의 for문과 같은 iteration 구조가 작동한다.

Iterator Protocol의 핵심:

  1. 어떤 객체가 __iter__() 메서드를 구현하면 iterable 객체가 된다. 즉 반복 가능해지게 된다.
  2. Iterator는 __iter__()__next__() 메서드를 모두 구현한다.
    • iterator의 __iter__()는 자기 자신을 반환해야 한다.

 

4. Iteration의 동작 원리 (for문 기준)

동작 단계:

  1. iter() 호출
    • for 문은 내부적으로 대상 iterable 객체에 iter()를 호출해 iterator 객체를 확보한다.
  2. next() 호출
    • 반복이 시작되면 iterator 객체에 next()를 호출해 순서대로 iterable의 값을 가져온다.
  3. 종료 조건
    • StopIteration 예외가 발생하면 반복을 종료한다.

아래 코드와 같은 방식으로 동작한다고 보면 된다:

my_list = [1, 2, 3]

iterator = iter(my_list)  # 1. iter() 호출

while True:
    try:
        item = next(iterator)  # 2. next() 호출
        print(item)
    except StopIteration:  # 3. StopIteration 예외 발생 시 반복 종료
        break

 

5. 커스텀 Iterable과 Iterator

임의의 클래스에서 iterator protocol을 구현하면, 직접 iterable 객체를 만들 수 있다.

iterable에는 __iter__() 메소드를 구현하고, iterator에는 __iter__()__next__() 메소드를 구현하면 된다.

class SimpleIterable:
    """0부터 지정된 개수만큼 숫자를 생성하는 iterable 클래스"""
    def __init__(self, count):
        self.count = count

    def __iter__(self):
        return SimpleIterator(self.count)


class SimpleIterator:
    """숫자를 하나씩 생성하는 iterator 클래스"""
    def __init__(self, count):
        self.current = 0
        self.count = count

    def __iter__(self):
        return self

    def __next__(self):
        if self.current >= self.count:
            raise StopIteration
        value = self.current
        self.current += 1
        return value


simple_iterable = SimpleIterable(5)

for number in simple_iterable:
    print(number)
0
1
2
3
4

 

Iterator 자체가 iterable로 동작할 수 있다. Iterator가 __iter__() 메서드를 구현하기 때문이다(자기 자신을 반환하는 함수).

따라서 iterator는 항상 iterable이기도 하다. 이 설계는 파이썬에서 흔히 사용하는 패턴으로, 복잡성을 줄이고 간결하게 반복 동작을 처리할 수 있도록 도와준다.

이를 통해 for 루프와 같은 반복 구조에서 iterator를 iterable처럼 직접 사용할 수도 있다.

class SimpleIterator:
    """숫자를 하나씩 생성하는 iterator 클래스"""
    def __init__(self, count):
        self.current = 0
        self.count = count

    def __iter__(self):
        return self  # 자기 자신을 반환

    def __next__(self):
        if self.current >= self.count:
            raise StopIteration
        value = self.current
        self.current += 1
        return value

# SimpleIterator를 직접 사용
iterator = SimpleIterator(3)

print(iter(iterator))  # 자기 자신을 반환
for number in iterator:
    print(number)

# 한 번 더 반복하면 아무것도 출력되지 않음 (소진된 iterator)
for number in iterator:
    print(number)
<__main__.SimpleIterator object at 0x000001F46BC9A450>
0
1
2

 

※ Iterator가 iterable의 역할을 동시에 수행할 수 있는데, 굳이 둘이 나눠진 이유는?

역할의 분리(Seperation of Concerns)

  • Iterable은 반복 가능한 데이터 구조를 정의한다. List, tuple, dict 등은 모두 __iter__() 메서드를 제공하여 반복을 시작할 준비가 되있는 객체들이다.
  • 반면에, iterator는 반복의 상태를 추적해서, 실제로 데이터를 제공하는 역할을 수행한다. __next__()를 통해 데이터를 하나씩 반환받는다.

이렇게 역할을 분리하면 2가지 장점이 있다:

  • 재사용성: iterable 객체는 iterate 요청이 들어올 때마다 새로운 iterator를 생성할 수 있다. 그래서 같은 iterable을 여러 for 루프에서 독립적으로 사용할 수 있다.
  • 상태 관리: iterator는 iterate 상태(현재 위치 등)을 내부적으로 유지한다. 반면 iterable은 데이터를 정의 및 제공만 하면 된다.

덕분에 동일한 iterable에 대해 여러 개의 iterator를 생성할 수 있다.

nums = [1, 2, 3]
iter1 = iter(nums) # 독립된 iterator
iter2 = iter(nums) # 독립된 iterator

print(next(iter1))  
print(next(iter2))
1
1

또한 iterable 객체를 여러 번 재사용 할 수 있다. (각 for문에서는 iterable로부터 매번 새로 iterator를 제공받는다.)

nums = [1, 2, 3]
for num in nums:
    print(num)

for num in nums:  # 반복 가능
    print(num)
1
2
3
1
2
3

iterator는 1회용이다. 여러 번 사용할 수 없으므로 1회 사용 이후 "소진" 되어 버린다.

따라서, 같은 데이터를 여러번 반복하기 위해서는 iterable에 대해 iter()를 호출해 새로운 iterator를 반환받는 과정이 필요하다 (4. Iteration의 동작 원리에 작성했듯이 for 문은 iterator 확보 동작을 내부에서 자동으로 처리한다)

nums = iter([1, 2, 3])
for num in nums:
    print(num)

for num in nums:  # 두 번째 반복 불가능
    print(num)
1
2
3

위와 같은 이유로 iterator와 iterator는 분리되었다. 하지만 1회성으로 반복하기 위한 간단한 케이스에서는 둘을 하나로 합쳐서 사용해도 상관 없다. 

 

6. 장점

  • 메모리 효율: 데이터를 한 번에 모두 메모리에 올리지 않고 필요한 시점에 값을 생성해 처리할 수 있다.
    • 이 내용은 list나 str 같이 이미 모든 데이터가 메모리 상에 존재하는 데이터구조에 대해서는 해당되지 않는 장점이다. list의 iterator는 단순히 메모리에 있는 데이터를 순차적으로 iterate하기 떄문에 메모리 효율이 발생하지 않는다.
    • 반면에 iterator와 generator를 잘 활용하면 데이터를 한 번에 모두 메모리에 올리지 않고, 필요한 값을 하나씩 생성해서 반환할 수 있다. "5. 커스텀 Iterable과 Iterator"의 SimpleIterable/SimpleIterator와 일반적으로 사용되는 range가 iterator가 이렇게 사용된 케이스에 해당된다. generator는 다른 원리로 동작하지만, 메모리를 효율적으로 사용한다는 점에서는 동일하다(generator에 대해서는 https://comgu.tistory.com/entry/Python-제너레이터Generator 참고).
  • 유연성: 모든 반복 가능한 객체를 통일된 방식으로 다룰 수 있다.
    • iter()와 next()라는 통일된 메서드를 사용해서 반복을 수행하는 메커니즘이 정립되어 있기 때문이다.

 

요약

  • 파이썬의 iterator protocol은 iterable과 iterator가 상호작용하는 규칙을 말한다. 이 규칙에 따라 파이썬의 for문과 같은 iteration 구조가 작동한다.
  • iterable은 list, str, range 등과 같이 iterate할 수 있는 객체를 말한다. iterable을 반환하는 __iter__() 메소드가 구현되있어야 한다.
  • iterable은 iterable 객체에서 값을 하나씩 꺼내오는 객체이다. 반드시 __iter__() __next__() 메서드를 가지고 있다.
  • for 문과 같은 반복문에서는 iterable와 iterator에 대해 iter()next()를 호출하여서, iteration을 처리할 수 있다.
  • iterator를 잘 활용하면 메모리를 효율적으로 사용할 수 있다.
  • Iterator Protocol은 iter()next()라는 표준 메서드를 제공하기 떄문에, 반복 가능한 객체를 일관적인 방식으로 다룰 수 있는 표준적인 API를 제공하는 것이다.
반응형
반응형

최근 파이썬에서 dict에 대한 iteration 동작에 대해 이해하게 된 부분을 정리하고자 한다.

student_score = {
    "희진": 97,
    "영희": 60,
    "다정": 77,
    "병수": 79,
    "창현": 89,
}

위 dict 정보에서, 가장 높은 점수를 가진 학생의 이름을 구하는 문제를 풀어본다.

여러 방법이 있는데, 아래처럼 max 함수와 key 지정을 사용하면 짧은 코드로 해결 가능하다.

max_name = max(student_score, key=student_score.get)
max_score = student_score[max_name]
print(f'점수가 가장 높은 학생은 {max_name}, 그 점수는 {max_score}점이다.')
점수가 가장 높은 학생은 희진, 그 점수는 97점이다.

위 코드에서 max 함수부분이 어떻게 동작하는 건지 자세하게 연구를 해봤다.

 

핵심 포인트: 파이썬의 dict에 대한 직접 iteration이 수행될 때는, Key만 순회한다. Value를 순회하지는 않는다.

student_score = {
    "희진": 97,
    "영희": 60,
    "다정": 77,
    "병수": 79,
    "창현": 89,
}

for key in student_score:
    print(key)
희진
영희
다정
병수
창현

위를 통해 dict에 대한 직접 iteration은, key 값만을 순회하는 것을 알 수 있다.

같은 이유로, 만약 max(student_score)를 호출하면 student_core의 key들만을 기준으로 최솟값을 찾는다. (즉 value에 대한 값 확인은 전혀 발생하지 않는다)

student_score = {
    "희진": 97,
    "영희": 60,
    "다정": 77,
    "병수": 79,
    "창현": 89,
}

print(max(student_score))
다정

 사전(알파벳 순으로) 최댓값을 찾기 때문에, "다정"이 출력된다.

 

max() 함수에 'key' 파라미터에 특정 함수를 지정하게 되면, dict의 key들 자체가 아닌, 각 key를 해당 함수에 아규먼트로 넘긴 결과값을 기준으로 정렬했을때에 최대 결과값을 가진 key가 max()의 리턴값으로 반환된다.

student_score = {
    "희진": 97,
    "영희": 60,
    "다정": 77,
    "병수": 79,
    "창현": 89,
}

max_name = max(student_score, key=student_score.get)
max_score = student_score[max_name]
print(f"점수가 가장 높은 학생은 {max_name}, 그 점수는 {max_score}점이다.")

여러 key들 중에서, student_score.get()에 각 key값을 아규먼트로 넘긴 결과값 중 최댓값은 97이므로, "희진"이 max()의 결과값이 된다.

-> max()는 각 key들에 대해 iteration을 돌면서, key를 하나씩 차례로 student_score.get()에 전달한다. student_score.get ()은 dict의 value 값을 반환한다. 그러면 max()는 이 반환된 값들을 기준으로 최소값을 찾고, 최소값에 매칭되는 key를 찾아서 리턴한다.

 

참고로, max의 key 파라미터에는 lambda 함수도 입력이 가능하다.

아래 예시처럼, lambda 함수는 key를 아규먼트로 받아서 value를 리턴하는 함수이므로 잘 동작한다.

max_name = max(student_score, key=lambda x: student_score.get(x))
max_score = student_score[max_name]
print(f"점수가 가장 높은 학생은 {max_name}, 그 점수는 {max_score}점이다.")
점수가 가장 높은 학생은 희진, 그 점수는 97점이다

 

이처럼 파이썬 dict에 대한 직접 iteration은 key에 대해서만 순회한다는 것을 확인했다.

그렇다면.. 대신 value에 대해서 순회하려면?

for value in student_score.values():
    print(value)
97
60
77
79
89

위처럼 for문에서는 임의로 values()를 지정해줘야 한다.

max() 함수에서 value들만을 대상으로 iterate하게 할 수 있는 방법은 없었다.

 

또는.. key와 value 모두에 대해 순회하려면?

for key, value in student_score.items():
    print(key, value)
희진 97
영희 60
다정 77
병수 79
창현 89

for 문에서 임의로 items()를 지정해줘야 한다. 각 iteration마다 (key, value)라는 tuple 형태로 출력된다.

 

위 items()를 활용해서도 본 문제의 해결이 가능하다. 각 (key, value) 쌍에 대해 iterate를 할 수 있기 때문이다.

lambda 함수를 활용하면 max에 넘길 별도의 key 파라미터 함수는 작성하지 않아도 된다.

student_score = {
    "희진": 97,
    "영희": 60,
    "다정": 77,
    "병수": 79,
    "창현": 89,
}

max_name, max_score = max(student_score.items(), key=lambda x: x[1])
print(f"점수가 가장 높은 학생은 {max_name}이며, 그 점수는 {max_score}점입니다.")

max() 함수는 이제 student_score라는 dict가 아닌 student_score.items() 라는 dict_items 라는 시퀀스를 iterate하게 된다.

이제 매 iteration 마다 (key, value) tuple을 순회하며, lambda 함수에는 아규먼트로 (key, value)가 전달된다. 

lambda 함수는 tuple을 받으면 그중에 두번째 값, 즉 value를 리턴한다.

따라서, max() 함수는 value를 기준으로 최댓값을 가지는 (key, value) 값을 리턴하게 된다.

 

결론

파이썬에서 dict에 대한 직접 iteration은 key에 대해서만 수행된다.

그리고 이 성질을 이용해서, max() 함수와 같은 곳에서 key iteration이 수행된다. max의 key 파라미터 함수는 각 iteration의 key를 아규먼트로 받아서 최댓값의 기준으로 삼을 특정 리턴값을 만들어낸다.

위 글에서는 max() 함수에 대해서만 다뤘는데, min() 함수에서도 key iteration 원리는 동일하게 적용된다.

반응형
반응형

1. Local 변수

Local 변수는 함수 안에서 정의되며, 해당 함수의 scope(범위) 내에서만 접근 가능한 변수이다. 함수 실행 중에만 존재하고, 함수 종료와 함께 메모리에서 제거된다.

Local 변수의 특징

1. Scope: local 변수가 정의된 함수 내로 제한된다.

2. Lifetime: 함수의 실행 중에만 존재한다.

3. 재정의: 이미 같은 이름의 global 변수가 있다고 해도 local 변수를 정의할 수 있다. 함수 내의 local 변수는 함수 scope 내에서 잠시 global 변수 이름을 shadowing한다.

Local 변수 예시

def f():
    a = 10  # Local 변수
    print(a)


f()
print(a)
10
Traceback (most recent call last):
    print(a)
NameError: name 'a' is not defined

Local 변수로서의 a는 잘 출력되지만, 함수 밖에서는 같은 이름으로 변수에 접근할 수 없다.

 

Local 변수의 이름 재정의 및 shadowing 상황 예시

a = "global"

def f():
    a = "local"
    print(a)  # local 변수를 가리킨다

f()
print(a)  # global 변수를 가리킨다
local
global

함수 내에 선언된 local 변수 a가, 바깥의 global 변수 a를 (함수 내에서는) 가려버리고 있음을 알 수 있다.

 

2. Global  변수

Global 변수는 함수, 클래스, 및 block 바깥에서 정의되며, 전체 스크립트 파일 모든 곳에서 접근이 가능하다. 

또한 함수 내에서도 접근이 가능하다(local 변수에 의해 shadow 되지 않은 상태여야 함).

Global 변수는 특정 scope에 제약받지 않으며, 프로그램 동작 중에는 계속해서 메모리 상에 유지된다.

1. Scope: 전체 프로그램에서 모두 접근 가능하다.

2. Lifetime: 프로그램의 실행 중에 존재한다. 그러나 명시적으로 delete하면 삭제된다.

3. 수정: 함수 내에서 global 변수를 수정하고자 한다면, 함수 내에서 해당 변수를 "global"로 정의해야 한다.

 

3. global 키워드

함수 내에서 global 변수 접근 예시

x = 10  # Global 변수

def f():
    print(x) # Global 변수에 접근

f()
10

함수 내에서 global 변수에 접근해서 읽기만 할 때는 global 키워드를 따로 사용하지 않아도 된다. 

 

함수 내에서 gloabl 변수를 수정 예시

a = 10  # Global 변수

def f():
    global a
    a = 50 # Global 변수 수정
    print(a)

f()
print(a)
50
50

global 키워드를 사용해서, 함수 내에서 global 변수에 접근해 값을 수정했음을 알 수 있다.

위 예시에서는 local 변수가 전혀 사용되지 않았다.

 

4. 다소 어려울 수 있는 케이스

그렇다면 아래 케이스도 살펴 보자. 

a = 100

def f():
    print(a)
    a = a + 1
    return a
    
print(f())
print(a)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 2, in f
UnboundLocalError: cannot access local variable 'a' where it is not associated with a value

global 키워드를 사용하진 않은 상태인데,  함수 내의 print(a) 에서 UnboundLocalError 에러가 발생한다. 

UnboundLocalError  에러가 발생하는 이유는, a = a + 1이라는 대입문이 있으므로, 파이썬은 a를 대입문에서 새로 정의되는 local 변수로 간주하기 때문이다. local 변수인 a가 어떤 값을 가지기 전에 print(a)에서 a에 접근하므로 위의 UnboundLocalError가 발생한다.

하지만 여기서 드는 의문 점은, "print(a)에서 사용하는 a는 local 변수가 아닌, global 변수 a이므로 100의 값을 갖지 않는가?"인 점이다.

비록 global 키워드를 사용하지는 않았지만, print(a) 자체는 global 변수 a를 수정하지 않았기 때문에 이런 의문이 드는 것이다.

즉 a = a + 1은 print(a)보다 뒤쪽에 있는데 이것을 통해 a가 수정된다는 점을 파이썬이 미리 파악해서, a를 수정하지 않는 print(a) 문이 접근하는 a는 local 변수인 것으로 간주해버린다는 것이다. 작동 원리의 이해가 다소 어려운 문제이다.

 

핵심 포인트: Scope Resolution

파이썬에서 local 변수는 함수의 컴파일 단계에서 결정된다. 그 말은 파이썬은 컴파일 시점에  a = a + 1 문을 보고, 함수 전체에서 a 가 지역 변수라는 사실을 결정해버린다는 뜻이다. 

파이썬은 변수들의 scope를 함수의 정의 시점에 정적으로 결정하며, 실행 시점에 동적으로 결정하지 않는다. 이것이 변수의 scope resolution이다. 

1. 컴파일 단계:

파이썬이 a = a + 1 문을 함수 바디에서 발견한다.

a를 함수 전체에 대해서 local 변수인 것으로 마크한다.(a = a + 1 문 이전에 a를 사용하는 코드가 있더라도)

2. 실행 단계:

함수 실행시, print(a)가 a에 접근한다.

파이썬이 local 변수인 a를 찾아내는데, 아직 a = a + 1이 실행되지 않았으므로 a가 초기화되지 않은 상태임을 확인한다.

그러므로 UnboudLocalError를 발생시킨다.

 

위 문제를 해결하려면, 2가지 방법이 있다.

1. global 변수 a를 사용한다.

a = 100

def f():
	global a
    print(a)
    a = a + 1
    return a
    
print(f())
print(a)
100
101
101

 

2. local 변수 a를 사용한다.

def f():
    a = 100  # Local 변수
    print(a)
    a = a + 1
    return a

print(f())
100
101

 

반응형

+ Recent posts