마리아노 아나야의 파이썬 클린코드를 읽고 정리한 포스트입니다.
주제
- 인덱스와 슬라이스를 이해하고 인덱싱 가능한 객체를 올바른 방식으로 구현하기
- 시퀀스와 이터러블 구현하기
- 컨텍스트 관리자를 만드는 모범 사례 연구 그리고 어떻게 효율적으로 작성할 수 있는지
- 매직 메서드를 사용해 보다 관용적인 코드 구현
- 파이썬에서 부작용을 유발하는 흔한 실수 피하기
인덱스와 슬라이스
파이썬에서는 음수 인덱스를 사용하여 끝에서부터 접근이 가능하다.
>>> a = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']
>>> a[0]
'a'
>>> a[5]
'f'
>>> a[-1]
'h'
>>> a[-2]
'g'
또한, 다음 명령과 같이 slice를 사용하여 특정 구간의 요소를 구할 수 있다.
>>> a[2:5]
['c', 'd', 'e']
>>> a[2:-1]
['c', 'd', 'e', 'f', 'g']
>>> a[3:]
['d', 'e', 'f', 'g', 'h']
위의 모든 예제는 실제로는 slice 함수에 파라미터를 전달하는 것과 같다. slice 함수는 파이썬의 내장 객체이므로 다음과 같이 직접 호출할 수도 있다.
>>> a[slice(2, 5)]
['c', 'd', 'e']
>>> a[slice(2, -1)]
['c', 'd', 'e', 'f', 'g']
slice의 (시작, 중지, 간격) 중 지정하지 않은 파라미터는 None으로 간주한다.
방금 설명한 기능은 __getitem__이라는 매직 메서드(매직 메서드는 파이썬에서 특수한 동작을 수행하기 위해 예약한 메서드로 이중 밑줄로 둘러싸야 있다) 덕분에 동작한다. 이것은 myobject[key]와 같은 형태를 사용할 때 호출되는 메서드로 key에 해당하는 대괄호 안의 값을 파라미터로 전달한다. 특히 시퀀스는 __getitem__과 __len__을 모두 구현한 객체이므로 반복이 가능하다. 리스트, 튜플과 문자열은 표준 라이브러리에 있는 시퀀스 객체이다.
다음 클래스는 객체가 어떻게 리스트를 래핑하는지 보여준다.
from collections.abc import Sequence
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)
클래스가 시퀀스임을 선언하기 위해 collections.abc 모듈의 Sequence 인터페이스를 구현해야 한다. 우리가 작성한 클래스가 컨테이너나 딕셔너리와 같은 표준 데이터 타입처럼 동작하게 하려면 이 모듈을 구현하는 것이 좋다. 이러한 인터페이스를 상송받으면 해당 클래스가 어떤 클래스인지 바로 알 수가 있으며, 필요한 요건들을 강제로 구현하게 되기 때문이다.
컨텍스트 관리자
컨텍스트 관리자는 패턴에 잘 대응되기 때문에 유용한 기능이다. 어떤 중요한 작업 전후에 반드시 수행해야 하는 작업이 있을 때 유용하다. 예를 들어 파일을 열고 닫는 작업이 있다고 가정해보자. 파일을 열고 작업을 수행한 후에는 반드시 파일을 닫아야 한다. 이러한 작업을 수행하기 위해 다음과 같이 try/finally 문을 사용할 수 있다.
file = open('hello.txt', 'w')
try:
file.write('hello, world!')
finally:
file.close()
그렇지만 똑같으 기능을 파이썬에서는 매우 우아하고 간결하게 구현할 수 있다.
with open('hello.txt', 'w') as file:
file.write('hello, world!')
with 문(PEP-343)은 컨텍스트 관리자로 진입하게 된다. 이 경우 open 함수는 컨텍스트 관리자 프로토콜을 구현한다. 즉 예외가 발생한 경우에도 블록이 완료되면 파일이 자동으로 닫힌다.
컨텍스트 관리자는 __enter__와 __exit__두 개의 매직 메서드로 구성된다. 첫 번째 줄에서 with 문은 __enter__메서드를 호출하고 이 메서드가 무엇을 반환하든 as 이후에 지정된 변수에 할당된다. 사실 __enter__메서드가 특정한 값을 반환할 필요는 없다. 설사 값을 반환한다고 해도 필요하지 않으면 변수에 할당하지 않아도 된다.
이 라인이 실행되면 다른 파이썬 코드가 실행될 수 있는 새로운 컨텍스트로 진입한다. 해당 블록에 대한 마지막 문장이 끝나면 컨텍스트가 종료되며 이는 파이썬이 처음 호출한 원래 컨텍스트 관리자 객체의 __exit__메서드를 호출함을 의미한다.
컨텍스트 관리자 블록 내에 예외 또는 오류가 있는 경우에도 __exit__메서드가 여전히 호출되므로 정리 조건을 안전하게 실행하는데 편하다. 실제로 __exit__메서드는 블록에서 예외가 발생한 경우 해당 예외를 파라미터로 받기 때문에 임의의 방법으로 처리할 수도 있다.
컨텍스트 관리자 구현
컨텍스트 관리자를 구현하는 방법은 여러 가지가 있다. 가장 간단한 방법은 클래스를 정의하고 __enter__와 __exit__메서드를 구현하는 것이다.
우선, 컨텍스트 관리자를 사용하지 않고 데이터베이스 백업을 수행하는 코드를 작성해보자. 주의 사항은 백업은 오프라인 상태에서 수행되어야 한다는 것이다. 즉 데이터베이스가 실행되고 있지 않은 동안에만 백업을 할 수 있으며 이를 위해 서비스를 중지해야 한다. 백업이 끝나면 백업 프로세스가 성공적으로 진행되었는지 관계없이 프로세스를 다시 시작해야 한다.
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()
참고로 __exit__의 반환 값을 잘 생각해야 한다. 특별한 작업을 할 필요가 없다면 아무것도 반환하지 않아도 된다. 만약 __exit__가 True를 반환하면 잠재적으로 발생한 예외를 호출자에게 전파하지 않고 멈춘다는 것을 뜻한다. 때로는 이렇게 하는 것을 원하는 경우도 있지만 일반적으로 발생된 예외를 삼키는 것은 좋지 않은 습관이다.
위의 예제를 컨텍스트 관리자를 사용하여 구현해보자. 이 클래스는 contextlib 모듈에 있는 contextmanager 데코레이터를 사용하여 구현할 수 있다. contextmanager 데코레이터를 적용하면 해당 함수의 코드를 컨텍스트 관리자로 변환한다. 함수는 제너레이터라는 특수한 함수의 형태여야 하는데 이 함수는 코드의 문장을 __enter__와 __exit__메서드로 분할한다.
from contextlib import contextmanager
@contextmanager
def db_handler():
try:
stop_database()
yield
finally:
start_database()
with db_handler():
db_backup()
먼저 제너레이터 함수를 정의하고 @contextmanager 데코레이터를 적용했다. 이 함수는 yield문을 사용했으므로 제너레이터 함수가 된다. yield문 앞의 모든 것은 __enter__메서드의 일부처럼 취급된다는 것이다. 여기서 생성된 값은 컨텍스트 관리자의 평가 결과로 사용된다. __enter__메서드의 반환 값과 같은 역할을 하는 것으로 as x: 와 같은 형태로 변수에 할당할 수 있다. 여기서는 아무것도 반환하지 않았다. 암묵적으로 None이 반환하는 것과 같다. 그러나 컨텍스트 관리자의 블록 내에서 사용하기를 원하는 경우 반환 값을 지정할 수도 있다.
이 지점에서 제너레이터 함수가 중단되고 컨텍스트 관리자로 진입하여 데이터베이스의 백업코드가 실행된다. 이 작업이 완료되면 다음 작업이 이어서 실행되므로 yield 문 다음에 오는 모든 것들을 __exit__로직으로 볼 수 있다.
또 다른 구현 방법은 contextlib.ContextDecorator를 상속받는 것이다. 이 클래스는 컨텍스트 관리자 안에 실행될 함수에 데코레이터를 적용하는 데 사용된다. 반면에 컨텍스트 관리자 자체의 로직은 앞서 언급한 매직 메서드를 구현하여 제공해야 한다.
from contextlib import ContextDecorator
class dbhandler(ContextDecorator):
def __enter__(self):
stop_database()
def __exit__(self, exc_type, ex_value, ex_traceback):
start_database()
@dbhandler()
def offline_backup():
run("pg_dump database")
이전 예제와 다른 점은 with문이 없다는 것이다. 그저 함수를 호출하기만 하면 offline_backup 함수가 컨텍스트 관리자 안에서 자동으로 실행된다. 이것이 원본 함수를 래핑하는 데코레이터가 하는 일이다. 데코레이터 객체에 직접 접근할 수 없기 때문에 변하지 않는 동일한 로직을 필요한 곳에 원하는 만큼 재사용할 수 있다.
마지막으로 contextlib.suppress라는 함수에 대해 알아보자. 이 함수는 안전하다고 확신하는 경우 해당 예외를 무시하는 기능이다. try/except 블록에서 코드를 실행하고 예외를 전달하거나 로그를 남기는 것과 비슷하지만 차이점은 suppress 메서드를 호출하면 로직에서 자체적으로 처리하고 있는 예외임을 명시한다는 점이다.
여기서 FileNotFoundError는 파일이 없어도 안전하므로 무시해도 된다는 뜻이다.
from contextlib import suppress
with suppress(FileNotFoundError):
os.remove('somefile.tmp')
컴프리헨션(Comprehension)과 할당 표현식
컴프리헨션 역시 코드를 간결하고 가독성있게 작성할 수 있는 기능이다. 그렇지만 수집한 데이터에 대해서 어떤 변환을 해야 하는 경우라면 오히려 가독성이 떨어질 수 있다. 이런 경우에는 for 루프를 사용하는 것이 더 좋다.
단일 명령어로 데이터 구조를 생성하려면 컴프리헨션을 사용하는 것이 좋다. 예를 들어, 다음과 같이 어떤 숫자들에 대해서 단순 계산이 포함된 리스트를 만들고 싶다면,
numbers = []
for i in range(10):
numbers.append(run_computation(i))
다음과 같이 바로 리스트를 만들 수도 있다.
numbers = [run_computation(i) for i in range(10)]
이러한 형식으로 작성된 코드는 list.append를 반복적으로 호출하는 대신 단일 파이썬 명령어를 호출하므로 일반적으로 더 나은 성능을 보인다.
클라우드 컴퓨팅 환경에서 ARN(Amazon Resource Name) 같은 문자열을 받아서 해당 리소스의 계정 정보를 반환하는 함수를 생각해보자. 이 함수는 다음과 같이 작성할 수 있다.
from typing import Iterable, Set
def collect_account_ids_from_arns(arns: Iterable[str]) -> Set[str]:
"""arn:partition:service:region:account-id:resource-id 형태의 ARN들이 주어진 경우
고유한 계정 ID (account-id)를 반환한다.
"""
collected_account_ids = set()
for arn in arns:
matched = re.match(ARN_REGEX, arn)
if matched:
account_id = matched.groupdict()['account_id']
collected_account_ids.add(account_id)
return collected_account_ids
이 함수는 비교적 간단하지만 많은 코드 라인을 차지한다. 이를 컴프리헨션을 사용하여 다음과 같이 간결하게 작성할 수 있다.
def collect_account_ids_from_arns(arns: Iterable[str]) -> Set[str]:
matched_arns = filter(None, map(re.match(ARN_REGEX, arn), arns))
return {m.groupdict()['account_id'] for m in matched_arns}
함수의 첫 번째 줄은 map과 filter를 적용하는 것과 비슷하다. 먼저 정규 표현식을 제공된 문자열에 적용하고 None이 아닌 것들만 남긴다. 남은 결과는 이터레이터로서 계정 ID를 추출하는 데 사용된다.
이 함수는 처음 함수보단 유지 보수가 쉽지만 파이썬 3.8이 나오면서 PEP-572(https://www.python.org/dev/peps/pep-0572/)에서 할당 표현식(assignment expression)이 도입되었다. 이 기능을 사용하면 다음과 같이 작성할 수 있다.
def collect_account_ids_from_arns(arns: Iterable[str]) -> Set[str]:
return {
matched.groupdict()['account_id']
for arn in arns
if (matched := re.match(ARN_REGEX, arn))
}
컴프리헨션 안쪽의 세 번째 줄에 있는 구문을 확인하자. 이렇게 하면 문자열에 정규식을 적용한 결과가 None이 아닌 것들만 matched 변수에 저장되고, matched 변수를 다른 변수에서 사용할 수 있다.
세 번째 코드가 두 번째 코드보다 더 나은 것인지 대해서는 논쟁의 여지가 있을 수 있지만, 첫 번째 코드보다는 낫다는 것은 분명하다. 더 간결한 코드가 항상 더 나은 코드는 아니다. 그러나 할당 표현식을 사용하는 또 다른 이유는 성능 때문이다. 어떤 변환 작업을 위해 호출하는 경우 필요 이상으로 호출되기를 원하지 않을 것이다. 뿐만 아니라 할당 표현식에서 함수의 결과를 임시 식별자에 할당하는 것은 코드의 가독성을 높이는 좋은 최적화 기술 중 하나이다.
프로퍼티, 속성(Attribute)과 객체 메서드의 다른 타입들
파이썬에서의 밑줄
파이썬은 객체의 모든 속성과 함수는 public이다. private이나 protected 접근 제어자를 사용해 사용범위를 제한하는 다른 언어와 다른 점이다. 그러나 파이썬에서는 속성과 메서드의 이름 앞에 밑줄을 붙여서 해당 속성이나 메서드가 private임을 나타낼 수 있다. 이러한 관례는 파이썬에서만 사용되는 것은 아니며, 다른 언어에서도 사용되는 관례이다.
그러나 이러한 관례는 완벽하게 private이라고 할 수 없다. 다음과 같이 객체의 속성을 직접 접근할 수 있다.
class Connector:
def __init__(self, source):
self.source = source
self._timeout = 60
conn = Connector("postgresql://localhost:5432")
>>> conn.source
'postgresql://localhost:5432'
>>> conn._timeout
60
>>> conn.__dict__
{'source': 'postgresql://localhost:5432', '_timeout': 60}
이처럼 속성을 직접 접근할 수 있기 때문에 완벽한 private이라고 할 수 없다. 그러나 파이썬에서는 속성에 접근할 때 다음과 같은 관례를 따르는 것이 좋다.
- 속성 이름 앞에 밑줄을 붙이면 해당 속성은 private이다.
- 속성 이름 앞에 밑줄을 두 개 붙이면 해당 속성은 맹글링(mangling)된다. 즉, 해당 속성은 외부에서 접근할 수 없다.
밑줄을 두 개 붙이면 외부에서 접근할 수 없지만 이것을 private이라고 오해해서는 안 된다. 만약 아래와 같이 timeout에 밑줄 두 개를 붙여 외부에서 접근하려고 한다면
class Connector:
def __init__(self, source):
self.source = source
self.__timeout = 60
conn = Connector("postgresql://localhost:5432")
>>> conn.__timeout
AttributeError: 'Connector' object has no attribute '__timeout'
“이것은 private이다” 또는 “이것은 접근할 수 없다”는 식으로 말하지 않고 존재하지 않는다고 말한다. 그리고 이것은 실제로 뭔가 다른 일로 벌여졌으며 부작용에 의한 결과로 생긴 것이라는 것을 암시한다.
파이썬에서 이중 밑줄을 사용하는 것은 완전히 다른 경우를 위한 것이다. 여러 번 확장되는 클래스의 메서드를 이름 충돌 없이 오버라이드하기 위해 만들어졌다.
프로퍼티
위와 반대로 객체의 일부 속성을 public으로 공개하고 싶은 경우에 대해서 살펴보자. 일반적으로 객체 지향 설계에서는 도메인 엔티티를 추상화하는 개체를 만든다. 도메인 엔티티는 데이터와 데이터를 조작하는 방법을 함께 묶어놓은 것이다. 이러한 개체는 데이터를 직접 노출하지 않고 데이터를 조작하는 방법만 노출한다. 이러한 개체를 캡슐화(encapsulation)라고 한다.
일부 엔티티는 데이터가 특정 값을 가질 경우에만 존재할 수 있고, 잘못된 값을 가진 경우에는 존재할 수 없다. 이것이 (일반적으로 setter 작업에 사용되는) 유효성 검사 메서드를 만드는 이유이다. 그러나 파이썬에서는 프로퍼티를 사용하여 이러한 setter와 getter 메서드를 더 간결하게 캡슐화할 수 있다.
예를 들어, 다음과 같이 좌표 값을 처리하는 지리 시스템을 생각해보자. 위도와 경도는 특정 범위에서만 의미가 있다. 해당 범위를 벗어나는 좌표는 존재할 수 없다. 좌표를 나타내는 객체를 생성할 수 있지만 어떤 값을 사용할 때는 항상 허용 가능한 범위 내에 있는지 확인해야 한다. 이런 경우 프로퍼티를 사용하여 좌표를 캡슐화할 수 있다.
class Coordinate:
def __init__(self, lat:float, long:float) -> None:
self._latitude = self._longitude = None
self.latitude = lat
self.longitude = long
@property
def latitude(self) -> float:
return self._latitude
@latitude.setter
def latitude(self, lat_value: float) -> None:
if lat_value not in range(-90, 90 + 1):
raise ValueError(f"유효하지 않은 위도 값: {lat_value}")
self._latitude = lat_value
@property
def longitude(self) -> float:
return self._longitude
@longitude.setter
def longtiude(self, long_value: float) -> None:
if long_value not in range(-180, 180 + 1):
raise ValueError(f"유효하지 않은 경도 값: {long_value}")
self._longitude = long_value
여기에서 프로퍼티는 latitude와 longtitude를 정의하기 위해 사용했다. 이렇게 함으로써 private 변수에 지정된 값을 반환하는 별도의 속성을 만들었다. 더 중요한 것은 사용자가 다음과 같은 방법으로 이러한 속성 중 하나를 수정하려는 경우이다.
coordinate.latitude = <new-latitude-value> # longtitude에 대해서도 동일하게 수정 가능
@latitude.setter 데코레이터로 선언된 유효성 검사 로직이 자동으로 호출되며 명령문의 오른쪽에 있는 값
객체의 모든 속성에 대해 get_, set_ 메서드를 작성할 필요는 없다. 대부분의 경우 일반 속성을 사용하는 것으로 충분하다. 속성 값을 가져오거나 수정할 때 특별한 로직이 필요한 경우에만 프로퍼티를 사용하자.
이렇게 객체의 상태나 내부 데이터에 따라 어떤 계산을 하고 싶은 경우 프로퍼티가 좋은 선택이다. 예를 들어, 특정 포맷이나 데이터 타입으로 값을 반환해야 하는 객체가 있는 경우 프로퍼티를 사용할 수 있다. 이전 예제에서 소수점 이하 네 자리까지의 좌표 값을 반환하기로 결정했다면(원래 숫자의 소수점 자릿수에 관계없이), 값을 읽을 때 호출되는 @property 메서드에서 반올림하는 계산을 만들 수 있다.
프로퍼티는 명령-쿼리 분리 원칙(command and query separation principle - CC08)을 따르기 위한 좋은 방법이다. 명령-쿼리 분리 원칙은 객체의 메서드가 무언가의 상태를 변경하는 커맨드이거나 무언가의 값을 반환하는 쿼리이거나 둘 중 하나만 수행해야지 둘 다 동시에 수행하면 안 된다는 것이다. 객체의 메서드가 무언가 기능을 수행 하면서 동시에 질문에 대답하기 위해 상태를 반환한다면 이는 동시에 두 가지 작업을 하고 있는 것이고 명령-쿼리 분리 원칙을 위배하는 것이다.
메서드 이름에 따라 실제 코드가 무엇을 하는지 혼돈스럽고 이해하기가 어려운 경우가 있다.
예를 들어, set_email이라는 메서드를 if self.set_email(“a@j.com”)처럼 사용했다면 이 코드는 무엇을 의미하는 걸까? a@j.com으로 이메일을 설정하려는 걸까? 이밎 이메일이 해당 값으로 설정되어 있는지 확인하려는 걸까? 아니면 동시에 이메일 값을 설정하고 상태가 유효한지 체크하려는 걸까?
프로퍼티를 사용하면 이런 종류의 혼동을 피할 수 있다. @property 데코레이터는 무언가에 응답하기 위한 쿼리이고, @
또 다른 조언 하나는 한 메서드에서 한 가지 이상의 일을 하지 말라는 것이다. 무언가를 할당하고 유효성 검사를 하고 싶으면 두 개 이상의 문장으로 나누어야 한다. 하나의 setter와 getter 메서드를 사용하는 것이 좋다. 각각은 사용자의 이메일을 설정하는 기능과 단순히 이메일 정보를 가져오는 일만 해야 한다. 왜냐하면 객체의 현재 상태를 구할 때마다 부작용 없이 (내부 표현을 변경하지 않고) 현재 상태를 그대로 반환해야 하기 때문이다. 이 규칙에 대한 유일한 예외는 게으른 프로퍼티(lazy property)이다. 딕셔너리에 어떤 값을 미리 계산한 다음에 그 계산된 값을 사용하는 것이다. 그 외의 경우라면 프로퍼티가 멱등성(idempotent - 연산을 여러 번 적용하더라도 결과가 달라지지 않는 성질)을 유지하도록 하고, 내부 표현을 변경하는 메서드를 따로 만들어야 한다. 두 가지 기능을 하나에 섞지 않도록 유의하자.
보다 간결한 구문으로 클래스 만들기
파이쎤에서는 객체의 값을 초기화하는 보일러플레이트(boilerplate code 또는 간단히 boilerplate - 모든 프로젝트에서 공통적으로 반복해서 사용되는 코드)가 있다. 일반적으로 __init__
메서드에 객체에서 필요한 모든 속성을 파라미터로 받은 다음 내부 변수에 할당한다. 이러한 코드는 다음과 같다.
def __init__(self, x, y, ...):
self.x = x
self.y = y
...
파이썬 3.7부터는 dataclasses 모듈을 사용하여 위 코드를 훨씬 단순화할 수 있다(이것은 PEP-557에서 소개된 개념이다.). 이전 장에서는 dataclasses가 어노테이션으로서 어떤 역할을 하는지 살펴보았는데, 여기에서는 더 간결한 코드를 작성하는 데 어떤 역할을 하는지 살펴볼 것이다.
@dataclass 데코레이터를 클래스에 적용하면 모든 클래스 속성에 대해서 마치 __init__
메서드에서 정의한 것처럼 인스턴스 속성으로 처리한다. __init__
메서드를 자동으로 생성하므로 또 다시 구현할 필요가 없다.
그런데 초기화 직후 유효성 검사를 하고 싶으면 어떻게 할까? 초기화 직후 호출되는 __post_init__
메서드를 정의하면 된다. 또는 값을 초기화하기 전에 어떤 계산을 하거나 로직을 추가하고 싶으면 어떻게 할까? 프로퍼티를 사용하면 된다.
다음은 dataclasses를 사용하여 좌표를 나타내는 클래스를 구현한 것이다. 또한 field(default_factory=…)를 사용하여 기본값을 설정할 수 있다.
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class Coordinate:
lat: str = field(default=None, metadata={'unit': 'degrees'})
long: str = field(default=None, metadata={'unit': 'degrees'})
elevation: Optional[float] = field(default=None, metadata={'unit': 'meters'})
def __post_init__(self):
if self.lat is None:
raise ValueError(f'invalid latitude: {self.lat}')
if self.long is None:
raise ValueError(f'invalid longitude: {self.long}')
복잡하게 유효성 검사를 하거나 변환을 하지 않는 데이터를 저장하려는 경우 dataclasses를 사용하는 것이 좋다. 다만, 어노테이션이 데이터 변환을 해주지 않는다는 것을 명심하자. 예를 들어, float 타입이거나 integer 타입이어야 한다면 __init__
메서드에서 변환을 해주어야 한다. 어노테이션만을 사용해서 데이터 클래스로 구현한다면 나중에 발견하기 힘든 버그가 발생할 수 있다.
이터러블 객체 만들기
파이썬의 반복 가능한 객체는 리스트(list), 튜플(tuple), 딕셔너리(dict), 집합(set) 등이 있다. 이러한 객체는 모두 __iter__
메서드를 구현하고 있으며, 이 메서드는 이터레이터(iterator)를 반환한다. 이터레이터는 __next__
메서드를 구현하고 있으며, 이 메서드는 다음 값을 반환한다.
직접 이터러블 객체를 만들고 싶다면 아래와 같이 __init__
, __iter__
, __next__
메서드를 구현하고 for 루프를 사용하여 이터러블 객체를 순회할 수 있다. 더 이상 반환할 값이 없으면 StopIteration 예외를 발생시킨다.
class Repeater:
def __init__(self, value):
self.value = value
self.count = 0
def __iter__(self):
return self
def __next__(self):
if self.count >= 5:
raise StopIteration
self.count += 1
return self.value
repeater = Repeater('Hello')
for item in repeater:
print(item)
>>> Hello
>>> Hello
>>> Hello
>>> Hello
>>> Hello
그런데 이렇게 구현하면 이터러블 객체를 한 번만 순회할 수 있다. StopIteration 예외가 발생하면 이터러블 객체는 더 이상 순회할 수 없다. 이러한 문제를 해결하기 위해 이터러블 객체를 다시 만들어야 한다. 이러한 문제를 해결하기 위해 파이썬에서는 제너레이터(generator)를 사용한다.
제너레이터는 이터러블 객체를 생성하는 간단한 방법이다. 제너레이터는 함수와 비슷하게 생겼지만, 함수와 다른 점은 yield 키워드를 사용한다는 것이다. yield 키워드는 제너레이터가 값을 반환하고 다음에 호출될 때까지 실행을 일시 중단한다는 것을 의미한다. 제너레이터는 다음과 같이 구현할 수 있다.
def repeater(value):
count = 0
while True:
if count >= 5:
return
count += 1
yield value
for x in repeater('Hello'):
print(x)
>>> Hello
>>> Hello
>>> Hello
>>> Hello
>>> Hello
각각의 for 루프는 __iter__
를 호출하고, __iter__
는 다시 제너레이터를 생성한다. 이러한 형태의 객체를 컨테이너 이터러블(container iterable)이라고 한다.
일반적으로 제너레이터를 사용할 때는 컨테이너 이터러블을 사용하는 것이 좋다. 제너레이터를 사용하면 메모리를 절약할 수 있기 때문이다. 제너레이터는 모든 값을 한 번에 생성하지 않고, 필요할 때마다 값을 생성한다. 그래서 제너레이터를 사용하면 메모리를 절약할 수 있다.
시퀀스 만들기
객체에 __iter__()
메서드를 정의하지 않았지만 반복하기를 원하는 경우도 있다. iter() 함수는 객체에 __iter__()
메서드가 정의되어 있지 않으면 __getitem__()
을 참고 없으면 TypeError를 발생시킨다.
시퀀스를 __len__
과 __getitem__
을 구현하고 첫 번째 인덱스 0부터 시작하여 포함된 요소를 한 번에 하나씩 차례로 가져올 수 있어야 한다. 즉 __getitem__
은 인덱스를 받아서 해당 인덱스의 값을 반환한다. __len__
은 시퀀스의 길이를 반환한다.
이터러블은 메모리를 절약할 수 있지만, 제너레이터는 인덱스를 사용하여 특정 요소를 가져올 수 없다. n번째 요소를 가져오려면 n번 반복해야 한다. 이 문제는 전형적인 메모리와 CPU 사이의 트레이드오프이다.
이터러블을 사용하면 메모리를 적게 사용하지만 n 번째 요소를 얻기 위한 시간복잡도는 O(n)이다. 시퀀스를 사용하면 메모리를 더 사용하지만 n 번째 요소를 얻기 위한 시간복잡도는 O(1)이다.
O(n)과 같은 표기법은 알고리즘의 효율적을 따질 때 가장 중요한 항의 성장률만 집중해서 비교하는 방법으로 빅 오(big-O) 표기법이라고 한다. O(n)은 n이 증가함에 따라 성능이 선형적으로 증가한다는 것을 의미한다. O(1)은 n이 증가함에 따라 성능이 선형적으로 증가하지 않는다는 것을 의미한다.
시퀀스를 이용한 예제를 살펴보자. __getitem__
, __len__
메서드를 구현하면 시퀀스를 만들 수 있다.
class Squares:
def __init__(self, length):
self.squares = [i ** 2 for i in range(length)]
def __len__(self):
return len(self.squares)
def __getitem__(self, i):
return self.squares[i]
squares = Squares(5)
for square in squares:
print(square)
>>> 0
>>> 1
>>> 4
>>> 9
>>> 16
>>> squares[2]
4
>>> len(squares)
5
>>> squares[2:4]
[4, 9]
>>> squares[-1]
16
컨테이너 객체
컨테이너는 __contains__
메서드를 구현한 객체로 일반적으로 Boolean 값을 반환한다. 이 메서드는 in 키워드가 발견될 때 호출된다. 예를 들면 다음과 같다.
>>> 5 in [1, 2, 3, 4, 5]
True
이 코드를 파이썬은 다음과 같이 해석한다.
>>> [1, 2, 3, 4, 5].__contains__(5)
True
객체의 동적인 속성
__getattr__
매직 메서드를 사용하면 객체가 속송에 접근하는 방법을 제어할 수 있다. <myobject>.<myattribute> 형태로 객체의 속성에 접근하려고 하면 파이썬은 객체 인스턴스의 속성(attribute) 정보를 가지고 있는 __dict__
딕셔너리에서
다음은 __getattr__
메서드를 사용하여 객체의 속성에 접근하는 방법을 제어하는 예제이다.
class DynamicAttributes:
def __init__(self, attribute):
self.attribute = attribute
def __getattr__(self, attr):
if attr.startwith("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라는 속성에 접근하려고 했다. 이 경우 attr=”fallback_test” 라는 파라미터와 함께 __getattr__
메서드가 호출된다. 위의 코드에서 __getattr__
메서드는 어떤 속성에 접근하려고 했었는지를 나타내느 오류 문자열을 반환한다.
세 번째 호출 예제는 fallback_new라는 새로운 속성을 추가하는 예제이다. 이 경우, dyn.fallback_new = “new value”처럼 작성한 것과 동일하다. 여기서는 __getattr__
메서드가 호출되지 않는다.
마지막은 getattr 함수를 사용하여 객체의 속성에 접근하는 예제이다. 이 함수는 객체의 속성에 접근할 때 사용한다. 이 함수는 세 번째 파라미터로 기본값을 지정할 수 있다. 만약 객체에 해당 속성이 없으면 기본값을 반환한다.
__getattr__
매직 메서드는 많은 상황에서 유용하게 쓰일 수 있다. 한 예로 다른 객체에 대한 프록시(proxy)역할을 할 수 있다. 예를 들어, 컴포지션(composition)을 통해 기존 객체의 위에서 동작하는 래퍼(wrapper)를 만든다고 가정하자. 이런 경우 기존 객체에서 가져오려는 메서드를 래퍼 객체에 그대로 중복해서 복사하는 대신에, __getattr__
메서드로 내부적으로 같은 이름의 메서드를 호출하면 쉽게 위임이 가능하다.
또 다른 예는 동적으로 계산되는 속성이 필요한 경우이다. 예를 들어, 객체의 속성에 접근할 때마다 속성의 값을 계산하고 싶은 경우가 있다. 이런 경우 __getattr__
메서드를 사용하여 속성에 접근할 때마다 계산된 값을 반환하도록 할 수 있다.
중복 코드가 많이 발생하거나 보일러플레이트(boilerplate) 코드가 많은 경우에도 __getattr__
매직 메서드가 좋은 선택이 될 수 있다. 하지만 이 메서드를 남용하면 코드의 가독성이 떨어지고 디버깅이 어려워진다. 그러므로 __getattr__
메서드를 사용할 때는 코드의 간결성과 관리 비용의 트레이드오프를 고려해야 한다.
호출형 객체(callable)
함수처럼 동작하는 객체를 만들면 편리한 경우가 있다. 가장 대표적인 활용 사례로는 데코레이터가 있는데, 꼭 데코레이터에 한정되지는 않는다. 객체를 일반 함수처럼 호출하면 __call__
매직 메서드가 호출된다. 이때 객체 호출 시 사용된 모든 파라미터는 __call__
메서드의 파라미터로 전달된다.
객체를 사용하는 가장 큰 장점은 객체에 상태를 저장할 수 있기 때문에 호출이 일어날 때 알맞은 정보를 저장하고 나중에 활용할 수 있다는 것이다. 즉, 어떤 기능을 호출할 때마다 관리해야 하는 상태가 있다면 따로 상태를 관리하는 함수를 만드는 것보다 호출형 객체를 만드는 것이 훨씬 더 편하다. 메모이제이션(memorization) 또는 내부 캐시 기능을 구현하면서 살펴보자.
파이썬은 object(*args, **kwargs)와 같은 구문으로 생성한 객체를 object.__call__(*args, **kwargs)
와 같은 형태로 변환한다. 이 메서드는 객체를 파라미터가 있는 함수처럼 사용하거나 정보를 기억하는 함수처럼 사용할 경우 유용하다.
다음은 동일한 파라미터 값으로 몇 번이나 호출되었는지를 카운트하기 위해 __call__
메서드를 사용하는 예제이다.
class CallCount:
def __init__(self):
self._count = defaultdict(int)
def __call__(self, argument):
self._count[argument] += 1
return self._count[argument]
>>> cc = CallCount()
>>> cc(1)
1
>>> cc(2)
1
>>> cc(1)
2
>>> cc(1)
3
>>> cc("something")
1
>>> callable(cc)
True
매직 메서드 요약
사용 예 | 매직 메서드 | 비고 |
---|---|---|
obj[key], obj[i:j:k] | __getitem__(key) | 첨자형(subscriptable) 객체 |
with obj: … |
__enter__ , __exit__
| 컨텍스트 관리자(context manager) |
for i in obj: … |
__iter__ /__next__
| 이터러블(iterable) 객체 |
for i in obj: … |
__len__ /__getitem__
| 시퀀스(sequence) 객체 |
obj.<attribute> | __getattr__ | 동적 속성 조회 |
obj(*args, **kwargs) | __call__(self, *args, **kwargs) | 호출형(callable) 객체 |
파이썬에서 유의할 점
이번 섹션에서 논의되는 대부분은 완전히 피할 수 있는 것들이며, 감히 안티 패턴을 정당화하는 시나리오가 거의 없다고 말할 수 잇다. 따라서 작업 중인 코드에서 이러한 코드를 발견하면 가능한 한 빨리 수정하는 것이 좋다. 코드 리뷰를 하는 동안 이런 특징을 발견하면 반드시 수정해야 한다는 분명한 신호이다.
변경 가능한(mutuable) 파라미터의 기본 값
파이썬에서는 함수의 기본 파라미터로 변경 가능한 객체를 사용하는 것은 좋지 않다. 만약 변경 가능한 객체를 기본 인자로 사용하면 기대와 다른 결과를 얻게 된다. 예를 들어, 다음과 같이 잘못 정의된 함수를 살펴보자.
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가 발생한다.
>>> wrong_user_display()
John (30)
>>> wrong_user_display({"name": "Jane", "age": 25})
Jane (25)
>>> wrong_user_display()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 2, in wrong_user_display
KeyError: 'name'
이유는 간단하다. 함수 정의에서 user_metadata의 기본값으로 사용한 딕셔너리는 함수가 정의될 때 한 번만 생성된다. 파이썬 인터프리터는 함수의 시그니처에서 딕셔너리를 기본값으로 하는 코드를 발견하면 해당 파라미터에 딕셔너리를 생성하여 할당한다. 이렇게 딕셔너리는 딱 한번만 생성되며 프로그램이 종료될 때까지 모든 객체의 인스턴스는 같은 기본 값을 참조한다.
게다가 이 예제에서는 프로그램이 종료될 때까지 공유될 초기값용 딕셔너리에 대해서 pop 메서드로 “name”과 “age” 키를 제거한다. 이제 다음에 초기값 객체를 사용하는 경우 “name”과 “age” 정보가 이미 제거되어 없어져버린 딕셔너리으로 초기화 된다.
수정 방법은 간단하다. 기본 초기 값으로 None을 사용하고 함수 본문에서 기본 값을 할당하면 된다. 각 함수는 자체 스코프와 생명주기를 가지므로 None이 나타날 때마다 user_metadata를 딕셔너리에 할당한다.
def user_display(user_metadata: dict = None):
user_metadata = user_metadata or {"name": "John", "age": 30}
name = user_metadata.pop("name")
age = user_metadata.pop("age")
return f"{name} ({age})"
내장(built-in) 타입 확장
리스트, 문자열, 딕셔너리와 같은 내장 타입을 확장하는 올바른 방법은 collections 모듈을 사용하는 것이다.
예를 들어, dict를 직접 상속 받아서 새로운 클래스를 만들다 보면 예상하지 못한 결과를 얻는 경우가 있다. 그 이유는 파이썬을 C로 구현한 CPython 코드가 내부에서 스스로 연관된 부분을 모두 찾아서 업데이트 해주지은 않기 때문이다. 예를들어, 딕셔너리의 key값을 가져오는 방식을 약간 수정하고 싶어서 __getitem__
메서드를 재정의 했다고 해본다. 그런데 이 메서드를 재정의하면 딕셔너리의 get
메서드가 제대로 동작하지 않는다. 이런 문제를 피하려면 collections, UserDict를 사용하는 것이 좋다. UserDict를 상속 받으면 관련된 모든 부분을 스스로 찾아서 업데이트 해주기 때문에 안전하다.
특정 위치의 아이템에 접근하려고 하면 접두어와 함께 해당 위치의 값을 문자열로 반환하는 사용자 정의 리스트를 생각해보자. 다음과 같이 구현하면 문제를 잘 해결한 것처럼 보이지만 실제로는 생각하지 못한 오류가 숨어 있다.
class BadList(list):
def __getitem__(self, index):
value = super().__getitem__(index)
if index % 2 == 0:
prefix = "짝수"
else:
prefix = "홀수"
return f"[{prefix}] {value}"
얼핏 보면 별다른 문제가 없어 보이지만, 막상 BadList로 만든 숫자형 리스트의 값을 join으로 합치려고 하면 다음과 같은 오류가 발생한다.
>>> bad_list = BadList([1, 2, 3, 4, 5])
>>> ", ".join(bad_list)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: sequence item 0: expected str instance, int found
join은 리스트(list), 문자열(string), 튜플(tuple)과 같이 반복 가능한 형태의 자료구조가 가진 문자형 원소들을 합치는 함수이다. (숫자형 원소를 가지고 있다면 오류가 발생한다.) BadList는 __getitem__
에서 문자열을 반환하므로 join을 사용할 수 없다.
이 문제는 사실 파이썬의 C 구현체인 CPython에서 발생하는 문제이며, 다른 구현체인 PyPy에서는 발생하지 않는다. 그러나 어떤 파이썬 구현체를 사용하더라도 이런 문제가 발생하지 않도록 하려면 list대신 collections의 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}"
Tip: 사용자 정의 딕셔너리를 만들려는 경우 dict를 직접 상속받지 말고 collections의 UserDict를 상속받도록 하자. 마찬가지로 리스트는 collections.UserList를 상속받고, 문자열이라면 collections.UserString을 상속받도록 하자.
비동기 코드(asynchronous code)에 대한 간략한 소개
이 섹션에서는 코루틴(coroutine)과 함계 동작한느 간단한 파이썬 구문을 소개한다.
코루틴은 함수와 비슷하지만, 함수가 호출되면 함수의 본문을 실행하는 대신 제어를 함수의 호출자에게 반환한다. 그리고 나중에 호출자가 제어를 다시 함수에게 반환할 수 있다. 이러한 특징을 통해 코루틴은 비동기적으로 동작할 수 있다.
비동기식 프로그래밍의 기본 아이디어는 중지(suspend) 가능한 코드가 있다면 그동안 다른 코드를 실행하자는 것이다. 일반적으로 I/O 작업을 할 때는 CPU를 다른 곳에 사용하고자 하는 경우가 많다.
여기에 프로그래밍 방식을 변경할 수 있는 여지가 있다. 동기적으로 코드를 호출하여 해당 기능이 종료될 때까지 기다리는 이벤트 루프(event loop)에서 우리의 코드를 호출하도록 변경하는 것이다. 이벤트 루프는 코루틴들을 스케쥴링하는 역할을 하며, 모두 하나의 동일한 스레드에서 실행한다.
일련의 코루틴을 만들고 이벤트 루프에 추가한다. 이벤트 루프가 시작되면 가지고 있는 코루틴 중에서 하나를 선택하여 실행한다. 코루틴 중 하나가 I/O 작업을 수행하면 이벤트 루프에게 권한을 넘긴다. 이벤트 루프는 I/O 작업이 진행되는 동안 다른 코루틴을 실행한다. I/O 작업이 종료되면 중지된 마지막 지점에서 다시 해당 코루틴을 시작한다. 비동기 프로그래밍의 장점은 I/O 작업이 진행되는 동안 다른 프로그램을 멈추지 않는다는 것이다. 다만, 이것은 I/O 작업을 하는 동안 다른 코드를 실행할 수 있다는 뜻이지만 동시에 여러 프로세스가 실행된다는 뜻은 아니다. 코드의 실행은 여전히 단일 스레드에서 진행된다.
파이썬에서 비동기 방식을 구현한 많은 프레임워크가 있다. 파이썬 3.5부터는 코루틴을 선언하기 위한 async/await 구문을 지원한다. 그보다 조금 앞서 기본 이벤트 루프 모듈인 asyncio도 표준 라이브러리에 추가되었다. 이러한 두 가지 주요 기능 덕분에 비동기 프로그래밍을 쉽게 구현할 수 있다.
비동기 프로그래밍을 위한 코루틴이 일반 함수와 다른 점은 이름 앞에 async def가 추가된다는 것이다. 코루틴 안에서 다른 코루틴을 호출하려는 경우 호출 전에 await 키워드를 사용한다. await가 호출되면 이벤트 루프가 제어권을 되찾아 온다. 이벤트 루프는 해당 코루틴이 종료되기를 기다리지 않고 다른 코루틴을 실행한다. 원래의 코루틴 작업이 완료되면 이벤트 루프는 중단 지점 바로 다음(await 문장 바로 다음 줄)에서부터 작업을 다시 시작한다.
일반적으로 코루틴은 다음과 같은 구조로 작성한다.
async def coroutine_name():
await some_function()
await some_other_function()
앞에서 언급했듯이 코루틴을 정의하기 위한 새로운 구문이 생겼다. 이 구문이 일반 함수랑 다른 점은 해당 라인을 호출할 때 함수가 바로 실행되지 않는다는 것이다. 대신 코루틴 객체를 생성한다. 이 객체는 이벤트 루프에 포함되어 await 해야만 실행이 된다.
result = await coroutine_name()
# result = coroutine_name() # 이렇게 호출하면 await 하지 않았다는 경고가 발생한다.
Tip: 코루틴을 호출할 때는 await 키워드를 사용해야 한다. 그렇지 않으면 경고가 발생한다.
앞서 언급했듯이 비동기 프로그래밍을 위한 여러 라이브러리가 있으며 코루틴을 실행할 수 있는 이벤트 루프를 제공한다. 특히 astyncio의 경우에는 해당 코루틴이 완료될 떄까지 대기하는 내장 함수도 제공한다.
import asyncio
ayncio.run(coroutine_name())
요약
이 장에서는 파이썬만의 독특한 특징을 살펴보았다. 다양한 메서드와 프로토콜 그리고 내부 동작 원리에 대해 알아보았다. 프로토콜이나 매직 메서드와 같이 파이썬의 핵심이 되는 개념과 아이디어에 대해서도 알아보았다. 파이썬스러운 관용적인 코드를 작성하는 가장 좋은 방법은 관용구를 따르는 것뿐만이 아니라 파이썬이 제공하는 모든 기능을 최댛나 활용하는 것이다. 즉, 매직 메서드, 컨텍스트 관리자, 또는 컴프리헨션(comprehension)이나 할당 표현식(assignement expression)을 사용하여 보다 간결한 코드를 작성함으로써 보다 유지 보수하기 쉬운 코드를 작성할 수 있게 되었다.
또한 비동기 프로그래밍에 대해서도 알게 되었고 코루틴을 사용하여 비동기 프로그래밍을 할 수 있게 되었다.
Previous
‘1. 코드 포매팅과 도구’ -> Previous
Next
‘3. 좋은 코드의 일반적인 특징’ -> Next