Python/core

__contains__

baecode 2025. 1. 25. 15:13
반응형

파이썬에서 요런 범위를 판단하는 코드를 작성해본적이 있을거에용.

a = 10
b = 20
c = 15
if a < c < b:
	print("범위내 포함!")

 

 

이 코드를 좀더 Pythonic 하게 작성하는 방법에 대해 알아봐요.

 

먼저 파이썬은 객체 지향 언어입니다.

여기서 우리는 판단할 범위를 클래스로 만들어 줄거에용

class MyRange:
    def __init__(self, min, max):
        self.min = min
        self.max = max

ma_range = MyRange(10,15)

print(ma_range.min) # 10

print(ma_range.max) # 15

 

자 ! 마레인지가 완성되었습니다.!

 

min과 max가 잘 출력 되고있습니다.

그러면 우리가 원하는 범위내에 포함되는 판단을 할수 있을까용?

print( 1 in ma_range)
print( 15 in ma_range)


---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[6], line 1
----> 1 print( 1 in ma_range)
      2 print( 15 in ma_range)

TypeError: argument of type 'MyRange' is not iterable

 

엥 ? 왜 안대용?

 

우리는 여기서 에러메세지만 보면 되용

 

TypeError: argument of type 'MyRange' is not iterable

 

MyRange는 iterable이 아니다.

 

맞아용 우리는 Myrange 클래스를 iterable하게 선언하지 않았습니다!

고쳐줍시다!

class MyRange:
    def __init__(self, min, max):
        self.min = min
        self.max = max

    def __iter__(self):
        return iter(range(self.min, self.max+1))

 

짠 이렇게하고 다시한번 해보죵

 

print( 1 in ma_range)
print( 15 in ma_range)

False
False

 

잘나와용~~ 근데 이렇게만하면 안되겠죠?

현재의 작은 단위에선 괜찮은데 우리 마렌지가 1부터10만이 된다면 어떻게 될까요? 속도로 확인해보죵

 

ma_range = MyRange(1, 100_000) # 화폐단위 구분에 쓰면 좋~습니다

start = time.time()

for i in range(10_000):
    (i+999_990) in ma_range
    
end = time.time()

print("Range   :", f"{end - start:.6f}초")

Range   : 15.263454초

 

세상에 15초나 걸렸어요 ㄷㄷㄷㄷ

15초면 우사인 볼트가 100m결승선을 들어오고도 약 5~6초가 남는 시간인데 , 컴퓨터가 그러면 안되겠죵?

 

우리는 __contains__를 선언해서 확인하는방법을 써볼거에요.

 

class MyRangeContains:
    def __init__(self, min, max):
        self.min = min
        self.max = max

    def __contains__(self, value):
        return self.min <= value <= self.max
        
  
 ma_con_range = MyRangeContains(1, 100_000)
  
  
start = time.time()

for i in range(10_000):
    (i+999_990) in ma_con_range  
    
end = time.time()

print("Contains:", f"{end - start:.6f}초")

Contains: 0.001277초

 

헉? 어째서 이렇게 차이가 많이나죠?

 

이유는 in 구문의 작동원리에 있습니다.

파이썬에서 in 구문은 __contains__를 먼저 찾고 없으면 __iter__를 찾아서 비교합니다.

 

두개의 시간복잡도를 비교하자면 이런 차이입니다.

 

__contains__

 

위 MyRangeContains 클래스에서  __contains__를 min <= x <= max로 선언했기때문에

x값이 범위 안에 있는지만  멤버십 개념으로 확인합니다.
이 경우에 시간복잡도는 O(1) 이 됩니다.

 

__iter__


위 MyRange 클래스 에는 선언해준 __contains__ 이 없기때문에
x in MyRange(min, max)를 하면 파이썬은 내부적으로 __iter__ 즉, min~max 까지의 모든값으로 반복을 통해 찾습니다.

이 경우에 시간복잡도는 O(n) 이 되어버립니다.

! 참고 !
Python의 내장 range 객체는 O(1) 멤버십 체크를 제공하기 때문에 O(1) 입니다.
하지만, 우리가 `iter(range(min, max + 1))`을 사용하면 Python이 range 객체를 직접 탐색하는 것이 아니라 이터레이터를 순차적으로 순회하면서 값을 찾게 되므로 O(n)이 됩니다.

 

오! 그러면 __contains__ 를 쓰면 만능이네요? 

 

아니에용 __contains__ 만 가진 클래스는 iterable 객체가 아니기 때문에 iterable로 동작하는 기능을 사용하려고했을때 오류가 납니당.

 

예시로 sum()을 사용할수없어요.

 

이 경우 해당 클래스에서 합산을 하려면 아래와 같이 나올거에용

###################################################
# MyRange를 사용한 합산

start = time.time()
total_range = sum(my_range)
end = time.time()
print(f"MyRange를 사용한 sum() 시간: {end - start:.6f}초")

MyRange를 사용한 sum() 시간: 0.008538초

###################################################
# sum()을 `__contains__` 기반으로 구현하면?
start = time.time()
total_con_range = 0
sum_contains = sum(x for x in range(1, 100_001) if x in my_range_contains)
end = time.time()
print(f"MyRangeContains를 사용한 합산 시간: {end - start:.6f}초")

MyRangeContains를 사용한 합산 시간: 0.132346초

 

contains만 선언된 클래스는 iterable한 객체가 아니기 때문에 `sum(my_range)` 같은 연산을 사용할 수 없습니다.

 

이 경우 불필요한 시간이 소요 됩니다.

이것을 보면, __contains__만 선언된 경우 전체 순회 작업에서 성능이 크게 불리하다는 사실을 알 수 있습니다.

 

 

어? 저건 너무 비효율적인 코드 같은데 range를 쓰면 되지 않나요?

맞습니다! 아래와 같이 하면 똑같은 동작을 합니다.

sum(range(my_con_range.min, my_con_range.max + 1))

 

 

하지만, 위쪽에서 보여드린 예시는 range 없이 __iter__가 없는 경우 어떻게 동작하는지 보여주기 위한 구현입니다.
즉, sum(range(...)) 을 사용하면 직접 연산할 수 있지만, 객체가 이터러블이 아니라는 근본적인 문제를 해결하지 못합니다.

 

따라서 매번 sum(range(...))을 사용하는 것은 Pythonic하지 않으며,  
대신 __iter__를 구현하는 것이 가장 이상적인 해결책입니다.

 

그러면 iterable한것도 필요하고 in 구문에서도 유리한 pythonic한 방법은 어떤것일까요? 

class MyRange:
    def __init__(self, min, max):
        self.min = min
        self.max = max
    def __iter__(self):
    	return iter(range(self.min,self.max+1))

    def __contains__(self, value):
        return self.min <= value <= self.max
        
 
final_range = MyRange(1, 100_000)

start = time.time()
print(sum(final_range))
end = time.time()
print(f"{end-start:.6f}초")
#0.001018초


start = time.time()
for i in range(10_000):
    (i+999_990) in final_range
end = time.time()
print(f"{end-start:.6f}초")
#0.001067초

 

이것을 보면, `__contains__`만 선언된 경우 전체 순회 작업에서 성능이 크게 불리하다는 사실을 알 수 있습니다.

 

반응형

'Python > core' 카테고리의 다른 글

Pydantic, Dataclass, TypedDict는 언제 쓰는 게 좋을까?  (1) 2025.03.27