본문 바로가기

python

파이썬 클린 코드 - 2장 (Pythonic code)

1. 인덱스와 슬라이스

>>> numbers = (1, 1, 2, 3, 5, 8, 12, 21)
>>> my_numbers[1:7:2]

 

위와 같이 시퀀스에 간격을 전달할 때 실제로는 슬라이스를 전달하는 것과 같다.

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

 

>>> interval = slice(1, 7, 2)
>>> numbers[interval]
(1, 3, 8)

>>> interval = slice(None, 3)
>>> numbers[interval] = numbers[:3]
True

 

slice 의 (시작, 중지, 간격) 중 하나를 지정하지 않은 경우 None 으로 간주한다.

 

튜플, 문자열, 리스트의 특정 요소를 가져오려고 한다면 for 루프를 돌며 수작업으로 요소를 선택하지 말고 이와 같은 방법을 사용하는 것이 좋다.

 

1-1. 자체 시퀀스 생성

위의 기능은 __getitem__ 매직 메서드 덕분에 동작한다. object[key] 와 같은 형태를 사용할 때 호출되는 메서드로 key 에 해당하는 대괄호 안의 값을 파라미터로 전달한다. 특히 시퀀스는 __getitem__ 과 __len__ 을 모두 구현하는 객체이므로 반복이 가능하다.

리스트, 튜플, 문자열은 시퀀스 객체의 예이다.

 

사용자 정의 클래스에 __getitem__ 을 구현하려는 경우 파이썬스러운 접근 방식을 따르기 위해 몇 가지를 고려해야 한다.

클래스가 표준 라이브러리 객체를 감싸는 래퍼인 경우 기본 객체에 가능한 많은 동작을 위임할 수 있다. 즉 클래스가 리스트의 래퍼인 경우 리스트의 동일한 메서드를 호출하여 호환성을 유지할 수 있다.

 

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

 

만약 래퍼도 아니고 내장 객체를 사용하지도 않는 경우에는 직접 시퀀스를 구현할 수 있다. 이 때 다음의 사항에 유의해야 한다.

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

리스트의 일부를 가져오면 결과는 리스트다. 튜플에서 특정 range를 요청하면 결과는 튜플이다. substring의 결과는 문자열이다. 각각의 경우에 결과가 원본 객체와 동일한 타입이라는 것을 알 수 있다.

 

range 함수의 경우 interval을 지정하여 호출하면 선택한 범위의 값을 생성하는 방법을 알고 있는 이터러블 객체를 반환한다.

 

>>> range(1, 100)[25:50]
range(26, 51)

 

마지막 요소를 제외해야 하는 것은 일관성에 관한 것이다. 예외를 만들면 혼란이 생길 수 있기 때문이다.

 

2. 컨텍스트 관리자(context manager)

컨텍스트 관리자는 사전조건과 사후조건을 가진 패턴에 잘 대응되는 기능이다. 주요 동작의 전후에 작업을 실행하려고 할 때 유용하다.

예를 들어 일단 파일을 열면 파일 디스크립터 누수를 막기 위해 작업이 끝나면 적절히 닫히길 기대한다. 또는 서비스나 소켓에 대한 연결을 열었을 때도 적절하게 닫거나 임시 파일을 제거하는 등의 작업을 해야 한다.

 

이 모든 경우에 일반적으로 할당된 모든 리소스를 해제해야 한다. 잘 처리됐을 때는 쉽지만 그렇지 않은 경우에는 가장 일반적으로 finally 블록에 정리 코드를 넣는다.

 

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

 

위의 같은 기능을 파이썬스러운 방법으로 구현할 수도 있다.

 

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

 

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

 

컨텍스트 관리자는 __enter__ 와 __exit__ 두 개의 매직 메서드로 구성된다. 첫 번째 줄에서 with 문은 __enter__ 메서드를 호출하고 이 메서드가 무엇을 반환하든 as 이후에 지정된 변수에 할당된다. 사실 __enter__ 메서드가 특정한 값을 반환할 필요는 없다. 값을 반환한다 하더라도 필요하지 않으면 변수에 할당하지 않아도 된다.

 

이 라인이 실행되면 다른 파이썬 코드가 실행될 수 있는 새로운 컨텍스트로 진입한다. 마지막 문장이 끝나면 컨텍스트가 종료되며 이는 파이썬이 처음 호출한 원래 컨텍스트 관리자 객체의 __exit__ 메서드를 호출함을 의미한다.

 

컨텍스트 관리자 블록 내의 예외 또는 오류가 있는 경우에도 __exit__ 메서드가 여전히 호출되므로 정리 조건을 안전하게 실행하는데 편하다. __exit__ 메서드는 예외가 발생한 경우 해당 예외를 파라미터로 받기 때문에 임의의 방법으로 처리할 수도 있다.

 

컨텍스트 관리자는 관심사를 분리하고 독립적으로 유지돼야하는 코드를 분리하는 좋은 방법이다.

예를 들어 데이터베이스 백업을 하려는 경우를 생각해보자. 주의 사항은 데이터베이스가 실행되지 않은 동안에만 백업을 할 수 있으며, 백업이 끝나면 백업 프로세스의 성공 여부와 관계없이 프로세스를 다시 시작해야 한다.

 

먼저 첫 번째는 거대한 단일 함수를 만드는 것이다.

 

run = print


def stop_database():
    run("systemctl stop postgresql.service")

def start_database():
    run("systemctl start postgresql.service")

class DBHandler:
    def __enter__(self):
        stop_database()
        return self

    def __exit__(self, exc_type, ex_value, ex_traceback):
        start_database()

def db_backup():
    run("pg_dump database")

def main():
    with DBHandler():
        db_backup()

 

위 예제에서는 DBHandler를 사용한 블록 내부에서 컨텍스트 관리자의 결과를 사용하지 않았다. 이 경우에 __enter__ 의 반환 값은 쓸모가 없다. 컨텍스트 관리자를 디자인할 때 블록이 시작된 후에 무엇이 필요한지 고려해야 한다. 필수는 아니지만 __enter__ 에서 무언가를 반환하는 것이 좋은 습관이다.

 

main() 함수에서는 유지보수 작업과 상관 없이 백업을 실행한다. 또한 백업에 오류가 있어도 여전히 __exit__ 를 호출한다.

 

__exit__ 메서드는 블록에서 발생한 예외를 파라미터로 받는다. 블록에 예외가 없으면 모두 None 이다.

__exit__ 메서드의 반환 값을 잘 생각해야 한다. 특별한 작업을 할 필요가 없다면 아무것도 반환하지 않아도 된다. 만약 __exit__ 메서드가 True를 반환하면 잠재적으로 발생한 예외를 호출자에게 전파하지 않고 멈춘다는 것을 뜻한다. 그러나 일반적으로 발생된 예외를 삼키는 것은 좋지 않은 습관이다.

 

실수로 __exit__ 에서 True 를 반환하지 않도록 주의해야 한다. 만약 True 를 반환한다면 이것이 정말 원하는 결과인지, 충분한 이유가 있는지 확인해야 한다.

 

2-1. 컨텍스트 관리자 구현

__enter__, __exit__ 매직 메서드만 구현하면 해당 객체는 컨텍스트 관리자 프로토콜을 지원할 수 있다. 표준 라이브러리 contextlib 모듈을 사용하여 보다 쉽게 구현할 수 있다.

 

먼저 contextmanager 데코레이터를 살펴보면, 함수에 contextlib.contextmanager 데코레이터를 적용하면 해당 함수의 코드를 컨텍스트 관리자로 변환한다. 함수는 제너레이터 형태여야 하는데 이 함수는 코드의 문장을 __enter__ 와 __exit__ 매직 메서드로 분리한다.

 

이전 예제를 contextmanager 데코레이터를 사용하여 다시 작성할 수 있다.

 

import contextlib

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

 

먼저 제너레이터 함수를 정의하고 @contextlib.contextmanager 데코레이터를 적용했다. 이 함수는 yield 문을 사용했으므로 제너레이터 함수가 된다. 데코레이터를 적용하면 yield 문 앞의 모든 것은 __enter__ 메서드의 일부처럼 취급된다. 여기서 생성된 값은 컨텍스트 관리자의 평가 결과로 사용된다. __enter__ 메서드의 반환 값과 같은 역할을 하는 것으로 as x: 와 같은 형태로 변수에 할당할 수 있다. yield 문에서 아무것도 반환하지 않으면 None 을 반환하는 것과 같다.

 

이 지점에서 제너레이터 함수가 중단되고 컨텍스트 관리자로 진입하여 데이터베이스의 백업코드가 실행된다. 이 작업이 완료되면 다음 작업이 이어서 실행되므로 yield 문 다음에 오는 모든 것들을 __exit__ 로직으로 볼 수 있다.

 

컨텍스트 관리자를 구현할 수 있는 많은 방법이 있으며 역시 contextlib 패키지에 있다.

 

또 다른 도우미 클래스는 contextlib.ContextDecorator 이다. 이 클래스는 컨텍스트 관리자 안에서 실행될 함수에 데코레이터를 적용하기 위한 로직을 제공하는 믹스인 클래스이다.

 

class dbhandler_decorator(contextlib.ContextDecorator):
    def __enter__(self):
        stop_database()

    def __exit__(self, ext_type, ex_value, ex_traceback):
        start_database()


@dbhandler_decorator()
def offline_backup():
    run("pg_dump database")

 

이전과 다른 점은 with 문이 없다는 것이다. 함수를 호출하기만 하면 offline_backup 함수가 컨텍스트 관리자 안에서 자동으로 실행된다. 이것이 원본 함수를 래핑하는 데코레이터가 하는 일이다.

 

데코레이터는 원본 함수에 대해 아무것도 모르고 그 반대도 마찬가지다. 이것은 좋은 특징이지만 컨텍스트 관리자 내부에서 사용하고자 하는 객체를 얻을 수 없다는 것을 의미한다. 예를 들어 다음과 같이 할당하려는 경우 with offline_backup() as bp: 이다. 따라서 __enter__ 메서드가 반환한 객체를 사용해야 하는 경우라면 이전의 접근 방식을 선택해야 한다.

 

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

 

contextlib.suppress 는 컨텍스트 관리자에서 사용하는 util 패키지로 제공한 예외 중 하나가 발생한 경우에는 실패하지 않도록 한다. try/ except 블록에서 코드를 실행하고 예외를 전달하거나 로그를 남기는 것은 비슷하지만 차이점은 suppress 메서드를 호출하면 로직에서 자체적으로 처리하고 있는 예외임을 명시한다는 점이다.

 

import contextlib

with contextlib.suppress(DataConversionException):
    parse_data(input_json_or_dict)

 

여기서 DataConversionException 은 입력 데이터가 이미 기대한 것과 같은 포맷이어서 변환할 필요가 없으므로 무시해도 안전하다는 뜻이다.

 

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

public, private, protected 프로퍼티를 갖는 다른 언어들과 다르게 파이썬 객체의 모든 프로퍼티와 함수는 public 이다. 즉, 호출자가 객체의 속성을 호출하지 못하도록 할 방법이 없다. 

 

엄격한 강제 사항은 없지만 몇 가지 규칙이 있다. 밑줄로 시작하는 속성은 해당 객체에 대해 private 을 의미하며, 외부에서 호출하지 않기를 기대하는 것이다. 다시 말하지만 이것을 금지하는 것은 아니다.

 

3-1. 파이썬에서의 밑줄

파이썬에서 밑줄을 사용하는 몇 가지 규칙과 구현 세부 사항이 있다.

 

class Connector:
    def __init__(self, source):
        self.source = source
        self._timeout = 60
        
>>> conn = Connector("postgresql://localhost")
>>> conn.source
'postgresql://localhost'
>>> conn._timeout
60
>>> conn.__dict__
{'source': 'postgresql://localhost', '_timeout': 60}

 

여기서 Connector 객체는 source 로 생성되며 앞서 언급한 source 와 timeout 이라는 두 개의 속성을 갖는다. timeout 은 private 임에도 접근 가능하다.

 

_timeout 은 connector 자체에서만 사용되고 호출자는 이 속성에 접근하지 않아야 한다. 내부에서만 사용되고 바깥에서는 호출되지 않아서 동일한 인터페이스를 유지하므로 언제든 필요한 경우에 안전하게 리팩토링할 수 있어야 한다.

 

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

 

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

 

이번에는 더블 언더 스코어로 정의했다고 가정해보자.

 

class Connector:
    def __init__(self, source):
        self.source = source
        self.__timeout = 60
    
    def connect(self):
        print("connection with {0}s".format(self.__timeout)
        
>>> conn = Connector("postgresql://localhost")
>>> conn.connect()
connection with 60s
>>> conn.__timeout
Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
    AttributeError: 'Connector' object has no attribute '__timeout'

 

밑줄 두 개를 사용하면 파이썬은 다른 이름을 만든다. 이를 name mangling 이라 한다. 

"_<class-name>__<attribute_name>" 의 경우 '_Connector__timeout' 이라는 속성이 만들어진다.

 

접근은 다음과 같이 할 수 있다.

>>> vars(conn)
{'source': 'postgresql://localhost', '_Connector__timeout': 60}
>>> conn._Connector__timeout
60
>>> conn._Connector__timeout = 30
>>> conn.connect()
connecting with 30s

 

파이썬에서 이중 밑줄을 사용하는 것은 private 을 만들기 위한 것과는 완전히 다른 목적이다. 여러 번 확장되는 클래스의 메서드를 이름 충돌 없이 오버라이드하기 위해 만들어졌다. 지금 예제가 이러한 메커니즘을 사용하기 위한 것이라고 보기 어렵다.

 

이중 밑줄은 파이썬스러운 코드가 아니다. 속성을 private 으로 정의하려는 경우 하나의 밑줄을 사용하고 파이썬스러운 관습을 지키도록 해야 한다.

 

3-2. 프로퍼티

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

 

프로퍼티는 객체의 어떤 속성에 대한 접근을 제어하려는 경우 사용한다. 이렇게 하는 것 또한 파이썬스러운 코드이다. 자바와 같은 다른 프로그래밍 언어에서는 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"Can't set {new_email} as it's not a valid email"
            )
        self._email = new_email

 

이메일 프로퍼티를 사용하여 공짜로 몇 가지 이점을 얻을 수 있다. 첫 번째 @property 메서드는 private 속성인 email 을 반환한다. 앞에서 언급했듯이 맨 앞에 있는 밑줄은 이 속성이 private 으로 사용될 것이므로 외부에서 접근하면 안 된다는 뜻이다.

두 번째 메서드는 앞에서 정의한 프로퍼티에 @email.setter 를 추가한다. 이 메서드는 <user>.email = <new_email> 이 실행될 때 호출되는 코드로 <new_email> 이 파라미터가 된다. 여기서는 설정하려고 하는 값이 실제 이메일이 아닌 경우 명확하게 유효성 검사를 한다.

 

>>> u1 = User("monsterkos")
>>> u1.email = "monsterkos@"
Traceback (most recent call last):
...
유효한 이메일이 아니므로 사용할 수 없음
>>> u1.email = "monsterkos@g.co"
>>> u1.email
'monsterkos@g.co'

 

이렇게 하면 get_, set_ 접두어를 사용하는 것보다 간단하다.

 

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

 

프로퍼티는 명령-질의 분리 원칙(command and query separation) 을 따르기 위한 좋은 방법이다. 명령-질의 분리 원칙은 객체의 메서드가 무언가의 상태를 변경하는 커맨드이거나 무언가의 값을 반환하는 쿼리이거나 둘 중에 하나만 수행해야 한다는 것이다.

 

메서드 이름에 따라 실제 코드가 무엇을 하는지 혼란스럽고 이해하기 어려운 경우가 있다. 예를 들어 set_email 이라는 메서드를 if self.set_email("a@j.com") 처럼 사용했다면 이 코드는 무엇을 의미하는 것일까?

a@j.com 으로 이메일을 설정하려는 걸까? 이미 이메일이 해당 값으로 설정되어 있는지 확인하려는 걸까? 아니면 동시에 이메일 값을 설정하고 강태가 유효한지 체크하려는 걸까?

 

프로퍼티를 사용하면 이런 종류의 혼란을 피할 수 있다. @property 데코레이터는 무언가에 응답하기 위한 쿼리이고, @<property_name>.setter 는 무언가를 하기 위한 커맨드이다.

 

메서드는 한 가지만 수행해야 한다. 작업을 처리한 다음 상태를 확인하려면 메서드를 분리해야 한다.

 

4. 이터러블 객체

파이썬에는 기본적으로 반복 가능한 객체가 있다. 튜플, 리스트, 세트 및 사전이 예이다.

이러한 내장 반복형 객체만 for 루프에서 사용 가능한 것은 아니다. 자체 이터러블을 만들 수도 있다. 엄밀히 말하면 이터러블은 __iter__ 매직 메서드를 구현한 객체, 이터레이터는 __next__ 매직 메서드를 구현한 객체를 말한다.

 

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

  • 객체가 __next__ 나 __iter__ 이터레이터 메서드 중 하나를 포함하는지 여부
  • 객체가 시퀀스이고 __len__ 과 __getitem__ 을 모두 가졌는지 여부

4-1. 이터러블 객체 만들기

객체를 반복하려고 하면 파이썬은 해당 객체의 iter() 함수를 호출한다. 이 함수가 처음으로 하는 것은 해당 객체에 __iter__ 메서드가 있는지를 확인하는 것이다. 만약 있으면 __iter__ 메서드를 실행한다.

 

from datetime import timedelta


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 day in DateRangeIterable(date(2019, 1, 1), date(2019, 1, 5)):
        print(day)

2019-01-01
2019-01-02
2019-01-03
2019-01-04
>>>

 

for 루프는 앞서 만든 객체를 사용해 새로운 반복을 시작한다. 이제 파이썬은 iter() 함수를 호출할 것이고, 이 함수는 __iter__ 매직 메서드를 호출할 것이다. __iter__ 메서드는 self 를 반환하고 있으므로 객체 자신이 이터러블임을 나타내고 있다. 따라서 루프의 각 단계에서마다 자신의 next() 함수를 호출한다. next() 함수는 다시 __next__ 메서드에게 위임한다. 이 메서드는 요소를 어떻게 만들고 하나씩 반환할 것인지 결정한다. 더 이상 만들 것이 없을 경우 파이썬에게 StopIteration 예외를 발생시켜 알려줘야 한다. 즉 for 루프가 작동하는 원리는 StopIteration 예외가 발생할 때까지 next() 를 호출하는 것과 같다.

 

>>> r = DateRangeIterable(date(2019, 1, 1), date(2019, 1, 4))
>>> next(r)
datetime.date(2019, 1, 1)
>>> next(r)
datetime.date(2019, 1, 2)
>>> next(r)
datetime.date(2019, 1, 3)
>>> next(r)
Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
    File ... __next__
        raise StopIteration
StopIteration
>>>

 

이 예제는 잘 동작하지만 문제가 하나 있다. 일단 한 번 실행하면 끝의 날짜에 도달한 상태이므로 이후에 호출하면 계속 StopIteration 예외가 발생한다. 즉, 두 개 이상의 for 루프에서 이 값을 사용하면 첫 번째 루프만 작동하고 두 번째 루프는 작동하지 않게 된다.

 

>>> r1 = DateRangeIterable(date(2019, 1, 1), date(2019, 1, 4))
>>> ", ".join(map(str, r1))
'2019-01-01, 2019-01-02, 2019-01-03'
>>> max(r1)
Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
ValueError: max() arg is an empty sequence
>>>

 

이 문제가 발생하는 이유는 반복 프로토콜이 작동하는 방식 때문이다. 이터러블 객체는 이터레이터를 생성하고 이것을 사용해 반복한다. 위의 예제에서 __iter__ 는 self 를 반환했지만 호출될 때마다 새로운 이터레이터를 만들 수 있다. 이 문제를 수정하는 한 가지 방법은 매번 새로운 DateRangeIterable 인스턴스를 만드는 것이다. __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)

 

이제 다음과 같이 잘 작동한다.

 

>>> r1 = DateRangeContainerIterable(date(2019, 1, 1), date(2019, 1, 4))
>>> ", ".join(map(str, r1))
'2019-01-01, 2019-01-02, 2019-01-03'
>>> max(r1)
datetime.date(2019, 1, 3)

 

달라진 점은 각각의 for 루프는 __iter__ 를 호출하고, __iter__ 는 다시 제너레이터를 생성했다는 것이다.

이러한 형태의 객체를 컨테이너 이터러블(container iterable) 이라고 한다.

 

일반적으로 제너레이터를 사용할 때는 컨테이너 이터러블을 사용하는 것이 좋다.

 

4-2. 시퀀스 만들기

객체에 __iter__() 메서드를 정의하지 않았지만 반복하기를 원하는 경우도 있다. iter() 함수는 객체에 __iter__가 정의되어 있지 않으면 __getitem__ 을 찾고 없으면 TypeError 를 발생시킨다.

 

시퀀스는 __len__ 과 __getitem__ 을 구현하고 첫 번째 인덱스 0부터 시작하여 포함된 요소를 한 번에 하나씩 차례로 가져올 수 있어야 한다. 즉 __getitem__ 을 올바르게 구현하여 이러한 인덱싱이 가능하도록 주의를 기울여야 한다.

 

이전 섹션의 예제는 메모리를 적게 사용한다는 장점이 있다. 한 번에 하나의 날짜만 보관하고 한 번에 하나씩 날짜를 생성하는 법을 알고 있음을 의미한다. 그러나 n번째 요소를 얻고 싶다면 도달할 때까지 n번 반복한다는 단점이 있다. 이 문제는 컴퓨터 과학에서 발생하는 전형적인 메모리와 CPU 사이의 트레이드 오프이다.

 

이터러블을 사용하면 메모리를 적게 사용하지만 n 번째 요소를 얻기 위한 시간복잡도는 O(n) 이다. 하지만 시퀀스로 구현하면 더 많은 메모리가 사용되지만 특정 요소를 가져오기 위한 인덱싱의 시간복잡도는 O(1) 로 상수에 가능하다.

 

새롭게 구현한 코드는 아래와 같다.

 

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)

 

이 객체는 아래와 같이 동작한다.

 

>>> s1 = DateRangeSequence(date(2019, 1, 1), date(2019, 1, 4))
>>> for day in s1:
        print(day)
    
2019-01-01
2019-01-02
2019-01-03
>>> s1[0]
datetime.time(2019, 1, 1)
>>> s1[2]
datetime.time(2019, 1, 3)
>>> s1[-1]
datetime.time(2019, 1, 3)

 

DateRangeSequence 객체는 모든 작업을 래핑된 객체인 리스트에 위임하기 때문에 음수 인덱스도 동작한다. 이렇게 하는 것은 호환성과 일관성을 유지하는 가장 좋은 방법이다.

 

두 가지 구현 중 어느 것을 사용할지 결정할 때 메모리와 CPU 사이의 트레이드 오프를 계산해보자. 일반적으로 이터레이션(제너레이터)이 더 좋은 선택이지만 모든 경우의 요건을 염두해야 한다.

 

5. 컨테이너 객체

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

 

element in container

 

이 코드는 파이썬은 다음과 같이 해석한다.

 

container.__contains__(element)

 

이 메서드를 잘 사용하면 코드의 가독성이 매우 높아진다.

 

2차원 게임 지도에서 특정 위치에 표시를 해야 한다고 가정해보자. 다음과 같은 함수를 생각할 수 있다.

 

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

 

여기서 첫 번째 if 문은 상당히 난해해 보인다. 코드의 의도가 무엇인지 파악하기 어렵고 직관적이지 않으며 무엇보다 매번 경계선을 검사하기 위해 if 문을 중복해서 호출한다.

 

지도에서 자체적으로 grid 라 부르는 영역을 판단해주면 어떨까? 그리고 그 일을 더 작은 객체에 위임하면 어떨까? 이렇게 하면 지도에게 특정 좌표가 포함되어 있는지만 물어보면 된다.

 

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

 

이 코드만으로도 훨씬 효과적인 구현이다. 무엇보다 구성이 간단하고 위임을 통해 문제를 해결한다.

 

def mark_coordinate(grid, coord):
    if coord in grid:
        grid[coord] = MARKED

 

6. 객체의 동적인 속성

__getattr__ 매직 메서드를 사용해 객체에서 속성을 얻는 방법을 제어할 수 있다. <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__} has no attribute {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__ 의 로직이 적용되지 않은 것에 유의해야 한다. 그 이유는 단순이 메서드가 호출되지 않았기 때문이다.

 

마지막은 값을 검색할 수 없는 경우 AttributeError 를 발생시키며, 기본 값을 반환한다.

 

__getattr__ 같은 동적인 메서드를 구현할 때는 AttributeError 를 발생시켜야 한다는 것에 주의해야 한다.

 

7. 호출형(callable) 객체

함수처럼 동작하는 객체를 정의하면 매우 편리하다. 가장 흔한 사례는 데코레이터를 만드는 것인데 이것뿐만 아니다.

매직 메서드 __call__ 을 사용하면 객체를 일반 함수처럼 호출할 수 있다. 여기에 전달된 모든 파라미터는 __call__ 메서드에 그대로 전달된다. 

 

객체를 이렇게 사용하는 주된 이점은 객체에는 상태가 있기 때문에 함수 호출 사이에 정보를 저장할 수 있다는 점이다.

파이썬은 object(*args, **kwargs) 와 같은 구문을 object.__call__(*args, **kwargs) 로 변환한다. 이 메서드는 객체를 파라미터가 있는 함수처럼 사용하거나 정보를 기억하는 함수처럼 사용할 경우 유용하다.

 

from collections import defaultdict


class CallCount:
    def __init__(self):
        self._counts = defaultdict(int)

    def __call__(self, argument):
        self._counts[argument] += 1
        return self._counts[argument]

 

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

 

 >>> cc = CallCount()
>>> cc(1)
1
>>> cc(2)
1
>>> cc(1)
2
>>> cc(1)
3
>>> cc("something")
1

>>> callable(cc)
True

 

8. 파이썬에서 유의할 점

8-1. 변경 가능한(mutable) 파라미터의 기본 값

변경 가능한 객체를 함수의 기본 인자로 사용하면 안된다. 

 

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 의 기본 인자이다. 이 함수는 인자를 사용하지 않고 처음 호출할 때만 동작한다. 그 다음에 호출할 때 명시적으로 user_metadata 를 전달하지 않으면 KeyError 가 발생한다.

 

user_metadata 기본 인자 값은 프로그램이 실행되는 동안에 계속 메모리에 남아있게 되는데 함수의 본체에서 객체를 수정하고 있다. 이 상태에서 함수의 파라미터에 값을 전달하면 조금 전에 사용한 기본 인자 대신 이 값을 사용한다. 다시 파라미터를 지정하지 않고 기본 값을 사용해 호출하면 실패하게 된다. 첫 번째 호출 시 key 를 지웠기 때문이다.

 

수정 방법은 간단하다. 기본 초기 값으로 None 을 사용하고 함수 본문에서 기본 값을 할당하면 된다. 각 함수는 자체 스코프와 생명주기를 가지므로 None 이 나타날 때마다 user_metadata 를 사전에 할당한다.

 

8-2. 내장(built-in) 타입 확장

리스트, 문자열, 사전과 같은 내장 타입을 확장하는 올바른 방법은 collections  모듈을 사용하는 것이다.

예를 들어 dict 를 직접 확장하는 클래스를 만들면 예상하지 못한 결과를 얻을 수 있다. 그 이유는 CPython 에서는 클래스의 메서드를 서로 호출하지 않기 때문에 메서드 중에 하나를 오버라이드 하면 나머지에는 반영되지 않아서 예기치 않은 결과가 발생한다. 예를 들어 __getitem__ 을 오버라이드 하고 for 루프를 이용해 객체를 반복하려고 하면 해당 로직이 적용되지 않는다.

 

collections.UserDict 를 사용하여 문제를 해결할 수 있다.

입력 받은 숫자를 접두어가 있는 문자열로 반환하는 리스트를 만든다고 가정해보자.

 

class BadList(list):
    def __getitem__(self, index):
        value = super().__getitem__(index)
        if index % 2 == 0:
            prefix = "짝수"
        else:
            prefix = "홀수"
        return f"[{prefix}] {value}"

 

위의 예제는 문제가 있다.

 

>>> bl = BadList((0, 1, 2, 3, 4, 5))
>>> bl[0]
'[짝수] 0'
>>> bl[1]
'[홀수] 1'
>>> "".join(bl)
Traceback (most recent call last):
...
TypeError: sequence item 0: expected str instance, int found

 

join은 문자열 리스트를 반복하는 함수이다. BadList 의 __getitem__ 에서 문자열을 반환했기 때문에 정확히 잘 맞는 문자열의 리스트라고 생각했지만 반복을 해보면 앞서 정의한 __getitem__ 이 호출되지 않는다.

 

이 문제는 CPython 의 세부 구현 사항이며 PyPy와 같은 다른 플랫폼에서는 재현되지 않는다.

그러나 어떤 상황에서도 이식 가능하고 호환 가능한 코드를 작성해야 하므로 리스트가 아니라 UserList 확장을 해야 한다.

 

from collections import UserList


class GoodList(UserList):
    def __getitem__(self, index):
        value = super().__getitem__(index)
        if index % 2 == 0:
            prefix = "짝수"
        else:
            prefix = "홀수"
        return f"[{prefix}] {value}"

 

이제 잘 동작하는 것을 볼 수 있다.

 

>>> gl = GoodList((0, 1, 2))
>>> gl[0]
'[짝수] 0'
>>> gl[1]
'[홀수] 1'
>>> "; ".join(gl)
'[짝수] 0; [홀수] 1; [짝수] 2'

 

dict 에서 직접 확장하지 말고 collections.UserDict 를 사용해야 한다. 리스트는 collections.UserList 를 사용하고 문자열이라면 collections.UserString 을 사용한다.