인덱스와 슬라이스

파이썬의 list는 음수 인덱스를 지원한다. 음수 인덱스를 사용해 배열의 끝에서부터 접근할 수 있다.

>>> my_numbers = (4, 5, 3, 9)
>>> my_numbers[-1]
9
 
>>> my_numbers[-3]
5

list slice를 사용하여 구간의 여러 element들을 가져올 수 있다.

>>> my_numbers = (1, 1, 2, 3, 5, 8, 13, 21)
 
# [0 ~ 3]
>>> my_numbers[:3]
(1, 1, 2)
 
# [3 ~]
>>> my_numbers[3:]
(3, 5, 8, 13, 21)
 
# 튜플의 복사본을 만든다.
>>> my_numbers[::]
(1, 1, 2, 3, 5, 8, 13, 21)
 
# [1 ~ 7, 간격 2]
>>> my_numbers[1:7:2]
(1, 3, 8)

slice는 파이썬 내장 객체로 직접 빌드하여 전달할 수도 있다.

>>> interval = slice(1, 7, 2)
 
>>> my_numbers[interval]
(1, 3, 8)
 
>>> interval = slice(None, 3)
>>> my_numbers[interval] == my_numbers[:3]
True

자체 시퀀스 생성

위에서 설명한 기능들은 __getitem__ 이라는 매직 메서드 덕분에 동작한다. 이것은 my_object[key]와 같은 형태를 사용할 때 호출되는 메서드로 key에 해당하는 대괄호 안의 값을 파라미터로 전달한다. 특히 시퀀스는 __getitem____len__을 모두 구현하는 객체이므로 반복이 가능하다. 리스트, 튜플, 문자열은 표준 라이브러리에 있는 시퀀스 객체의 예이다.

class Items:
    def __init__(self, *values):
        self._values = list(values)
    
    def __len__(self):
        return len(self._values)
    
    def __getitem__(self, item):
        return self._values.__genitem__(item)

만약 래퍼도 아니고 내장 객체를 사용하지도 않는 경우는 자신만의 시퀀스를 구현할 수 있다. 시퀀스 구현시 주의사항

  • 범위로 인덱싱 하는 결과는 해당 클래스와 같은 타입의 인스턴스여야 한다.
  • slice에 의해 제공된 범위는 마지막 요소를 제외한다.

컨텍스트 관리자

컨텍스트 관리자 패턴은 주요 동작의 전후에 작업을 실행하려고 할 때 유용하다. 일반적으로 리소스 관리와 관련하여 컨텍스트 관리자를 자주 볼 수 있다.

작업이 끝날 시 일반적으로 할당된 모든 리소스를 해제해야 하는데, 예외나 오류를 감안하는 쉬운 방법은 finally 블록에 정리 코드를 넣는 것이다.

fd = open(filename)
try:
    process_file(fd)
finally:
    fd.close()

위 예제와 같은 기능을 pythonic한 방법으로 구현할 수도 있다.

with open(filename) as fd:
    process_file(fd)

여기에서 open 함수는 컨텍스트 관리자 프로토콜을 구현한다. 즉 예외가 발생한 경우에도 블록이 완료되면 파일이 자동으로 닫힌다.

컨텍스트 관리자는 __enter____exit__ 두 개의 매직 메서드로 구성된다. 첫 번째 줄에서 with문은 __enter__ 메서드를 호출하고 반환값을 as 뒤의 변수에 지정한다. with문이 끝나면 컨텍스트가 종료되며 __exit__메서드를 호출한다.

컨텍스트 관리자 블록 내에 예외 또는 오류가 있는 경우에도 __exit__ 메서드가 여전히 호출되므로 정리 조건을 안전하게 실행하는데 편리하다.

컨텍스트 관리자 구현

함수에 contextlib.contextmanager 데코레이터를 적용하면 해당 함수의 코드를 컨텍스트 관리자로 변환한다. 이 함수는 제러레이터여야하고, 코드의 문장을 __enter____exit__ 매직 메서드로 분리한다.

@contextlib.contextmanager
def db_handler():
    stop_database()
    yield 
    start_database()
 
with db_handler():
    db_backup( )

@contextlib.contextmanager 데코레이터를 적용하면 yield 문 앞의 코드는 __enter__ 로직으로 취급되며, 생성된 값은 컨텍스트 관리자의 return값으로 사용된다. yield문 다음에 오는 코드는 __exit__ 로직으로 취급한다.

contextlib.ContextDecorator 클래스를 상속함으로써 컨텍스트 관리자를 데코레이터로 만들 수 있다.

class dbhandler_decorator(contextlib.ContextDecorator):
    def __enter__(self):
        stop_database()
    
    def __exit__(self, exc_type, exc_val, exc_tb):
        start_database()
 
@dbhandler_decorator()
def offline_backup():
    run("pg_dump database")

위 코드는 offline_backup()를 실행할 때 dbhandler_decorator의 context를 하당한다.

이 접근법의 유일한 단점은 독립적이라는 것이다. 데코레이터는 함수에 대해 모르고 그 반대도 마찬가지이다. 이것은 좋은 특성이지만 컨텍스트 관리자 내부에서 사용하고자 하는 객체를 얻을 수 없다는 것을 의미한다.

로직을 한번만 정의하면 동일한 로직을 필요로 하는 함수에 데코레이터를 적용함으로써 원하는 만큼 재사용 할 수 있다는 장점이 있다.

프로퍼티, 속성과 객체 메서드의 다른 타입들

다른 언어들과 달리 파이썬 객체의 모든 프로퍼티와 함수는 public이다. 엄격한 강제 사항은 없지만 몇 가지 규칙이 있다. 밑줄로 시작하는 속성은 해당 객체에 대해 private를 의미하며, 외부에서 호출하지 않기를 기대한다.

파이썬에서의 밑줄

객체는 외부 호출 객체와 관련된 속성과 메서드만을 노출해야 한다. 즉 객체의 인터페이스로 공개하는 용도가 아니라면 모든 멤버에는 접두사로 하나의 밑줄을 사용하는 것이 좋다.

이것은 객체의 인터페이스를 명확하게 구분하기 위한 pythonic한 방식이다.

파이썬에서 이중 밑줄로 정의한 속성에 접근하려고 하면 AttributeError 가 발생한다. 이와 같은 방법을 사용하여 일부 속성을 숨길 수 있으므로 해당 속성이 private이며 다른 객체가 수정할 수 없다고 생각할 수 있다. 하지만 발생한 예외는 AttributeError 에러로 접근 권한에 대해 언급하지 않고 속성이 없음을 나타낸다. 이것은 실제로 뭔가 다른 일이 벌어졌으며 부작용에 의한 결과로 생긴 것이라는 것을 암시한다.

밑줄 두 개를 사용하면 실제로 파이썬은 다른 이름을 만든다. 이것을 name mangling 이라고 한다. 이것이 하는 일은 다음과 같은 이름의 속성을 만드는 것이다. _[클래스이름]__[멤버이름]

이중 밑줄은 pythonic한 코드가 아니다. 속성을 private로 정의하려는 경우 하나의 밑줄을 사용하고 pythonic한 관습을 지켜야 한다.

프로퍼티

객체에 값을 저장해야 할 경우 일반적인 속성(attribute)을 사용할 수 있다. 객체의 상태나 다른 속성의 값을 기반으로 계산을 수행하려는 경우 대부분 프로퍼티를 사용하는 것이 좋은 선택이다.

프로퍼티는 객체의 속성에 대한 접근을 제어하려는 경우 사용한다. Java와 같은 다른 프로그래밍 언어에서는 접근 메서드(getter, setter)를 만들지만 파이썬에서는 프로퍼티를 사용한다.

import re
 
EMAIL_FORMAT = re.compile(r"[^@]+@[^@]+\\.[^@]+")
 
def is_valid_email(potentially_valid_email: str):
    return re.match(EMAIL_FORMAT, potentially_valid_email) is not None
 
class User:
    def __init__(self, username):
        self.username = username
        self._email = None
 
    @property
    def email(self):
        return self._email
 
    @email.setter
    def email(self, new_email):
        if not is_valid_email(new_email):
            raise ValueError(f"유효한 이메일이 아니므로 {new_email} 값을 사용할 수 없음")
        self._email = new_email

이 예제에서 첫 번째 @property메서드는 private속성인 email값을 반환한다. 두 번째 메서드는 앞에서 정의한 프로퍼티에 @email.setter을 추가한다. 이 메서드는 <user>.email = <new_email>이 실행될 때 호출되는 코드로 <new_email>이 파라미터가 된다. 여기에서는 설정하고자 하는 값이 실제 이메일 주소가 아닌 경우 명확하게 유효성 검사를 한다. 이메일 포맷에 문제가 없으면 새 값으로 속성을 업데이트 한다.

객체의 모든 속성에 대해 get_, set_ 메서드를 작성할 필요는 없다. 대부분의 경우 일반 속성을 사용하는 것으로 충분하고, 속성 값을 가져오거나 수정할 때 특별한 로직이 필요한 경우에 프로퍼티를 사용한다.

프로퍼티는 명령-쿼리 분리 원칙을 따르기 위한 좋은 방법이다. 메서드는 한 가지만 수행해야 한다. 작업을 처리한 다음 상태를 확인하려면 메서드를 분리해야 한다.

이터러블 객체

파이썬의 반복은 이터러블 프로토콜이라는 자체 프로토콜을 사용해 동작한다. 객체를 반복할 수 있는지 확인하기 위해 파이썬은 고수준에서 다음 두가지를 차례로 검사한다.

  1. 객체가 __next____iter__ 이터레이터 메서드 중 하나를 포함하는지 여
  2. 객체가 시퀀스이고 __len____getitem__을 모두 가졌는지 여부

이터러블 객체 만들기

class DateRangeIterable:
    """자체 이터레이터 메서드를 가지고 있는 이터러블"""
 
    def __init__(self, start_date, end_date):
        self.start_date = start_date
        self.end_date = end_date
        self._present_day = start_date
 
    def __iter__(self):
        return self
 
    def __next__(self):
        if self._present_day >= self.end_date:
            raise StopIteration
        today = self._present_day
        self._present_day += timedelta(days=1)
        return today

이 객체는 한 쌍의 날짜를 통해 생성되며 해당 기간의 날짜를 반복하면서 하루 간격으로 날짜를 표시한다.

for 루프는 iter() 함수를 호출하고, 이 함수는 __iter__ 매직 메서드를 호출한다. __iter__ 메서드는 이터러블 객체를 반환한다. 각 루프마다 반환된 이터러블 객체의 next()함수를 호출한다. next()함수는 다시 __next__ 메서드에게 위임한다. 이 메서드는 요소를 어떻게 생산하고 하나씩 반환할 것인지 결정한다. 더이상 생산할 것이 없을 경우 파이썬에게 StopIteration 예외를 발생시켜야 한다.

위 예제는 한 번 실행하면 끝 인덱스에 도달한 상태이므로 이후에 호출하면 StopIteration예외가 발생한다. 즉 두 개 이상의 for루프에서 이 값을 사용하면 첫 번째 루프만 작동시키고 두 번째 루프는 작동하지 않게 된다.

이 문제를 수정하는 한 가지 방법은 매번 새로운 DataRangeIterable 인스턴스를 만드는 방법이 있다. 또는 __iter__에서 제너레이터를 사용할 수도 있다.

class DateRangeContainerIterable:
    def __init__(self, start_date, end_date):
        self.start_date = start_date
        self.end_date = end_date
 
    def __iter__(self):
        current_day = self.start_date
        while current_day < self.end_date:
            yield current_day
            current_day += timedelta(days=1)

이러한 형태의 객체를 컨테이너 이터러블(container iterable)이라고 한다. 일반적으로 제너레이터를 사용할 때는 컨테이너 이터러블을 사용하는 것이 좋다.

시퀀스

시퀀스는 __len____getitem__을 구현하고 첫 번째 인덱스 0부터 시작하여 포함된 요소를 한 번에 하나씩 차례로 가져올 수 있어야 한다.

class DateRangeSequence:
    def __init__(self, start_date, end_date):
        self.start_date = start_date
        self.end_date = end_date
        self._range = self._create_range()
 
    def _create_range(self):
        days = []
        current_day = self.start_date
        while current_day < self.end_date:
            days.append(current_day)
            current_day += timedelta(days=1)
        return days
 
    def __getitem__(self, day_no):
        return self._range[day_no]
 
    def __len__(self):
        return len(self._range)

컨테이너 객체

컨테이너는 __contains__ 메서드를 구현한 객체로 __contains__ 메서드는 일반적으로 Boolean 값을 반환한다. 이 메서드는 파이썬에서 in 키워드가 발견될 때 호출된다.

def mark_coordinate(grid, coord):
    if 0 <= coord.x < grid.width and 0 <= coord.y < grid.height:
        grid[coord] = MARKED

위 코드의 첫번째 if 문은 난해해 보인다. 코드의 의도가 무엇인지 이해하기 어렵고 직관적이지 않으며 매번 if문을 중복해서 호출한다.

class Boundaries:
    def __init__(self, width, height):
        self.width = width
        self.height = height
 
    def __contains__(self, coord):
        x, y = coord
        return 0 <= x < self.width and 0 <= y < self.height
 
 
class Grid:
    def __init__(self, width, height):
        self.width = width
        self.height = height
        self.limits = Boundaries(width, height)
 
    def __contains__(self, coord):
        return coord in self.limits

객체의 동적인 속성

__getattr__은 인스턴스에 해당 attribute가 없을 경우 호출된다. 이 매직 메서드를 사용해 객체에서 속성을 얻는 방법을 제어할 수 있다. <myobject>.<myattribute>를 호출하면 파이썬은 객체의 딕셔너리에서 <myattribute>를 찾아서 __getattribute__를 호출한다. 객체에 찾고있는 속성이 없는 경우 속성의 이름을 파라미터로 전달하여 __getattr__이라는 추가 메서드가 호출된다. 이 값을 사용하여 반환 값을 제어할 수 있다.

class DynamicAttributes:
    def __init__(self, attribute):
        self.attribute = attribute
 
    def __getattr__(self, attr):
        if attr.startswith("fallback_"):
            name = attr.replace("fallback_", "")
            return f"[fallback resolved] {name}"
        raise AttributeError(f"{self.__class__.__name__} 에는 {attr} 속성이 없음")

다음은 이 클래스 객체에 대한 호출이다.

>>> dyn = DynamicAttributes("value")
 
>>> dyn.attribute
'value'
 
>>> dyn.fallback_test
'[fallback resolved] test'
 
>>> dyn.__dict__["fallback_new"] = "new value"
>>> dyn.fallback_new
'new value'
 
>>> getattr(dyn, "something", "default")
'default'

첫 번째 방법은 객체에 있는 속성을 요청하고 그 결과 값을 반환한다. 두 번째 방법은 객체에 없는 fallback_test라는 메서드를 호출하기 때문에 __getattr__이 호출되어 값을 반환한다. 세 번째 예제에서는 fallback_new라는 새로운 속성이 생성된다. 실제로 이 호출은 dyn.fallback_new = "new value"를 실행한 것과 동일하다. 메서드가 호출되지 않았기 때문에 __getattr__의 로직이 적용되지 않는다.

마지막 예제의 __getattr__메서드에서 값을 검색할 수 없는 경우 AttributeError 가 발생한다. 이것은 예외 메시지를 포함해 일관성을 유지할 뿐만 아니라 내장 getattr() 함수에서도 필요한 부분이다. 이 예외가 발생하면 getattr() 함수는 기본 값을 반환한다.

__getattr__과 같은 동적인 메서드를 구현할 때는 예외시 AttributeError를 발생시켜야 한다

호출형(callable) 객체

매직메서드 __call__을 사용하면 객체를 일반 함수처럼 호출할 수 있다. 여기에 전달된 모든 파라미터는 __call__메서드에 그대로 전달된다. 객체를 이렇게 사용하는 주된 이점은 객체에는 상태가 있기 때문에 함수 호출 사이에 정보를 저장할 수 있다는 점이다.

이 메서드는 객체를 파라미터가 있는 함수처럼 사용하거나 정보를 기억하는 함수처럼 사용할 경우 유용하다.

다음은 __call__메서드를 사용하여 입력된 파라미터와 동일한 값으로 몇 번이나 호출되었는지를 반환하는 객체를 만든 예이다.

class CallCount:
    def __init__(self):
        self._counts = defaultdict(int)
 
    def __call__(self, argument):
        self._counts[argument] += 1
        return self._counts[argument]

매직 메서드 요약

코드매직 메서드파이썬 컨셉
obj[key]
obj[i:j]
obj[i:j:k]
__getitem__(key)첨자형 객체(subscriptable) 객체
with obj: …enter/__exit__컨텍스트 관리자
for in obj: …__iter__/__next__, __len__/__getitem__이터러블 객체 시퀀스
obj.__getattr__동적 속성 조회
obj(*args, **kwargs)__call__(*args, **kwargs)호출형(callable) 객체

파이썬에서 유의할 점

본 섹션에서 논의되는 대부분은 완전히 피할 수 있는 것들이며, 안티 패턴을 정당화 하는 시나리오가 거의 없다.

변경 가능한 파라미터의 기본 값

def wrong_user_display(user_metadata: dict = {"name": "John", "age": 30}):
    name = user_metadata.pop("name")
    age = user_metadata.pop("age")
 
    return f"{name} ({age})"

이 함수는 변경가능한 인자를 사용한 것 외에도 함수 본문에서 가변 객체를 수정하여 부작용이 발생한다. 이 함수는 파라미터 없이 호출시 처음 한 번만 정상적으로 동작한다. 그 다음 호출할 때 명시적으로 user_metadata를 전달하지 않으면 KeyError가 발생한다.

>>> wrong_user_display()
'John (30)'
 
>>> wrong_user_display({"name": "jane", "age": 25})
'Jane (25)'
 
>>> wrong_user_display()
KeyError 'name'

기본 값을 사용해 함수를 호출하면 기본 데이터로 사용될 딕셔너리를 한 번만 생성하고 user_metadata는 이것을 가리킨다. 이 값은 프로그램이 실행되는동안 계속 메모리에 남아있게 되며, 값을 수정할 경우 기본 인자 대신 수정된 값을 사용한다. 이 문제를 해결하기 위해서는 기본 초기값으로 None을 사용하고 함수 본문에서 기본 값을 할당하면 된다.

내장(built-in) 타입 확장

리스트 , 문자열, 딕셔너리와 같은 내장 타입을 확장하는 올바른 방법은 collections 모듈을 사용하는 것이다. 내장 타입을 직접 확장하는 클래스를 만들면 예상하지 못한 결과를 얻을 수 있다. CPython에서는 클래스의 메서드를 서로 호출하지 않기 때문에 메서드 중에 하나를 오버라이드하면 나머지에는 반영되지 않기 떄문이다. 사용자 정의 매핑 타입을 만들 때에는 dict보다 collections.UserDict를 사용하는 것이 좋다.