파이썬 클린 코드 – 2
파이썬스러운(pythonic) 코드
인덱스와 슬라이스
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
my_numbers = (4, 5, 3, 9) print(my_numbers[-1]) # 9 print(my_numbers[-3]) # 5 my_numbers = (1, 1, 2, 3, 5, 8, 13, 21) print(my_numbers[2:5]) # (2, 3, 5) print(my_numbers[:3]) # (1, 1, 2) print(my_numbers[3:]) # (3, 5, 8, 13, 21) print(my_numbers[::]) # (1, 1, 2, 3, 5, 8, 13, 21) print(my_numbers[1:7:2]) # (1, 3, 8) interval = slice(1, 7, 2) print(my_numbers[interval]) # same as my_numbers[1:7:2] interval = slice(None, 3) print(my_numbers[interval] == my_numbers[:3]) # True |
자체 시퀀스 생성
- 대괄호 기능은 사실
__getitem__
메소드를 호출한다. - 특히 시퀀스는
__getitem__
,__len_
를 구현해 놓은 객체로 반복이 가능하다. - 만약 어떤 클래스가 표준 라이브러리 객체를 감싸는 래퍼라면 표준 라이브러리의 동일한 메소드를 호출하도록 위임할 수 있다.
1 2 3 4 5 6 7 8 9 10 |
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) |
- 하지만 래퍼가 아닌 자신만의 시퀀스를 구현할 때에는 다음에 유의하자.
- 범위로 인덱싱하는 결과는 해당 클래스와 같은 타입의 인스턴스여야 한다.
- 리스트의 일부를 가져오면 리스트이다.
- 튜플에서 range를 요청하면 결과는 역시 튜플이다.
- substring의 결과는 문자열이다.
- slice에 의해 제공된 범위는 파이썬이 하는 것처럼 마지막 요소는 제외해야 한다.
- 범위를 처리할 때 일관성을 유지하여야 한다.
- 특수한 동작을 한다면 사람이 기억하기 어렵기 때문에 버그가 생길 수 있다.
컨텍스트 관리자 (context manager)
- 리소스 관련하여 컨텍스트 관리자를 볼 수 있다.
- 보통 리소스를 할당받아 처리할 때 예외가 발생하면 리소스를 해제하여야 한다.
- finally 블록을 사용하면 다음과 같이 된다.
1 2 3 4 5 6 |
fd = open(filename) try: process_file(fd) finally: fd.close() |
- 하지만 똑같은 기능을 파이썬스러운 방법으로 구현하였다.
1 2 3 |
with open(filename) as fd: process_file(fd) |
open
함수는 컨텍스트 관리자 프로토콜을 구현한다.- 컨텍스트 관리자는
__enter__
와__exit__
두개의 메소드로 구성된다.with
문 처음에서__enter__
를 호출하고 그 반환 값을as
이후 변수에 할당한다.- 블록이 끝나면
__exit__
가 호출된다. - 예외가 있더라도
__exit__
가 호출되고, 파라미터로 예외를 받기 때문에 적절한 처리를 하도록 구현할 수도 있다.
- 리소스 관리가 아니더라도, 블록 전후로 필요한 어떤 로직을 구현하는데 사용하는 경우도 있다.
- 전후로 필요한 로직과 실제 동작을 분리하기에 편리하다.
- 예를 들어 DB 백업을 하는 기능을 구현한다고 하였을 때, DB 서비스를 중단하고 백업을 수행한 후 다시 서비스를 시작하여야 한다고 하자.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
def stop_database(): print("systemctl stop postgresql.service") def start_database(): print("systemctl start postgresql.service") class DBHandler: def __enter__(self): stop_database() return self def __exit__(self, exc_type, exc_val, exc_tb): start_database() def db_backup(): print("pg_dump database") def main(): with DBHandler(): db_backup() |
__exit__
를 보면 예외를 파라미터로 받는 것을 볼 수 있다. 예외가 없으면 모두None
이다-
__exit__
가True
를 반환하면 발생한 예외를 호출자에게 전파하지 않는다는 것을 의미한다.
컨텍스트 관리자 구현
- contextlib 모듈은 컨텍스트 관리자를 구현하ㅎ고, 더 간결한 코드를 작성하는데 도움이 되는 많은 헬퍼 함수와 객체를 제공한다.
- 함수에
contextlib.contextmanager
데코레이터를 적용하면 해당 함수의 코드를 컨텍스트 관리자로 변환한다. 함수는 제너레이터라는 특수한 함수여야 한다. - 다음은 이전과 동일한 코드를
contextmanager
데코레이터를 사용하여 다시 작성한 것이다.
1 2 3 4 5 6 7 8 9 10 11 |
import contextlib @contextlib.contextmanager def db_handler(): stop_database() yield start_database() with db_handler(): db_backup() |
yield
를 사용해서 이 함수는 제너레이터가 되었다. 이yield
앞의 것은__enter__
메소드처럼 취급된다.yield
문의 반환 값은__enter__
메서드의 반환 값의 역할을 한다.-
yield
를 지나면 잠시 코드가 중간되고db_backup()
코드가 실행된다. 작업이 완료되면 다시yield
문 다음의 코드가 실행되는데 이 부분이__exit__
코드에 해당하는 부분이 된다. -
contextmanager
를 사용하면 기존 함수를 그대로 재활용할 수 있다는 장점이 있다. 매직 메서드를 모두 추가하다보면 구현이 복잡해 지게 마련이다. - 또 다른 헬퍼는
contextlib.ContextDecorator
이다. 이 클래스는 컨텍스트 관리자로 실행할 함수를 데코레이터로 적용할 수 있도록하는 믹스인 클래스이다.
1 2 3 4 5 6 7 8 9 10 11 |
class dbhandler_decorator(contextlib.ContextDecorator): def __enter__(self): stop_database() def __exit__(self): start_database() @dbhandler_decorator def offline_backup(): run("pg_dump database") |
- 이렇게 하면
with
문 없이도 함수를 호출만 하면 컨텍스트 관리자 내에서 동작하게 된다. - 이 방법은 서로 독립적이라는 면에서 장점이지만, 컨텍스트 내부의 객체에 접근할 수 없다는 단점이 있다.
contextlib.suppress
는 지정한 예외 중 하나가 발생한 경우에는 예외를 발생시키지 않도록 한다.
1 2 3 4 5 |
import contextlib with contextlib.suppress(DataConversionException): parse_data(input_json_or_dict) |
프로퍼티, 속성과 객체 메서드의 다른 타입들
- 다른 언어와는 다르게 파이썬 객체의 모든 프로퍼티나 함수는 public 이다. 따라서 다른 객체가 호출하지 못하도록 할 방법이 없다.
- 강제는 아니지만 밑줄로 시작하는 속성은 private를 의미하는 관습이 있다.
파이썬에서의 밑줄
1 2 |
class Connector: def __init__(self, source): self.source = source self._timeout = 60conn = Connector("postgresql://localhost")print(conn.source)# postgresql://localhostprint(conn._timeout)# 60print(conn.__dict__)# {'source': 'postgresql://localhost', '_timeout': 60} |
- 이렇게 밑줄에 상관없이 모두 접근이 가능하지만,
_timeout
은 내부에서만 사용되고 외부에서는 호출하지 말아야 한다. - 밑줄로 시작하는 속성은 내부에서만 사용되고 바깥에서는 접근하지 않으므로 언제든 리팩토링이 가능하여야 한다.
- 이 규칙을 준수하면 객체의 인터페이스를 유지할 수 있어 파급 효과에 대해 걱정하지 않아도 되어 유지보수가 쉽고 견고한 코드를 작성할 수 있다.
1 2 |
class Connector: def __init__(self, source): self.source = source self.__timeout = 60 def connect(self): print("connecting with {0}s".format(self.__timeout))conn = Connector("postgresql://localhost")conn.connect()# connecting with 60sprint(conn.__timeout)# AttributeError: 'Connector' object has no attribute '__timeout' |
- 밑줄을 두개를 사용하면 private과 같은 동작을 한다고 알려져 있으나 이것은 사실이 아니다.
- 밑줄 두개를 사용하면 이름을 맹글링한ㄷ. 즉
_<class-name>__<attribute-name>
의 형식으로 이름을 바꿔 버린다.
1 2 |
print(vars(conn))# {&#039;source&#039;: &#039;postgresql://localhost&#039;, &#039;_Connector__timeout&#039;: 60}print(conn._Connector__timeout)# 60conn._Connector__timeout = 30conn.connect()# connecting with 30s |
- 위와 같이 접근이 가능하다. 이는 여러 번 상속받는 경우 클래스의 메서드를 이름 충돌 없이 오버라이드하기 위해 만들어졌다.
- 따라서 의도한 경우가 아니라면 private의 의미를 위해서는 하나의 밑줄을 사용하자.
프로퍼티
- 객체에 값을 저장해야 할 경우 보통 attribute를 사용한다.
- 속성에 대한 적븐을 제어하려는 경우 프로퍼티를 사용한다. 이는 자바에서의 getter, setter에 해당하는 것이다.
1 2 |
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"Invalid email") self._email = new_email u1 = User("jsmith")u1.email = "jsmith@"# ValueError: Invalid emailu1.email = "jsmith@g.co"print(u1.email)# jsmith@g.co |
-
이렇게 하면 private으로 표시한 변수에 접근하게 되며,
get_
,set_
을 사용하지 않아 더 간단한다. -
프로퍼티는 명령-쿼리 분리 원칙(command and query separation - CC08)을 따르기 위한 좋은 방법이다.
- 명령-쿼리 분리 원칙은 객체의 메서드가 상태를 변경하는 커맨드이거나 무언가의 값을 반환하는 쿼리이거나 둘 중에 하나만을 수행해야한다는 것이다.
이터러블 객체
- 내장된 반복형 객체 외에도 사용자 객체에도 이터러블을 만들 수 있다.
- 이를 위해서는
__iter__
메서드를 구현하고 이터레이터는__next__
메서드를 구현하면 된다. for e in myobject:
를 실행하기 위해서 파이썬은 다음 두가지를 검사한다.- 객체가
__next__
나__iter__
메서드는 하나를 포함하는 지 여부 - 객체가 시퀀스이고
__len__
과__getitem__
을 모두 가졌는지 여부
- 이를 위해서는
이터러블 객체 만들기
- 객체를 반복하려고 하면 파이썬은 객체의
__iter__
가 있는지 확인하고 있으면 이 메서드를 호출한다.
1 2 |
from datetime import timedeltafrom datetime import dateclass 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 todayfor 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 루프는 StopIteration이 발생할 때 까지
next()
를 호출하게 된다.
1 2 |
r = DateRangeIterable(date(2019, 1, 1), date(2019, 1, 5))next(r)# 2019-01-01next(r)# 2019-01-02next(r)# 2019-01-03next(r)# 2019-01-04next(r)# StopIteration |
- 이 클래스는 문제가 있는데, 끝에 한 번 도착하면 계속
StopIteration
이 발생한다. - 이를 회피하기 위해 매번 인스턴스를 만들 수도 있지만, 제너레이터를 사용할 수도 있다.
1 2 |
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, 5))print(", ".join(map(str, r1)))# 2019-01-01, 2019-01-02, 2019-01-03, 2019-01-04print(max(r1))# 2019-01-04 |
- 이와 같은 형태를 컨테이너 이터러블이라고 한다.
시퀀스 만들기
- 시퀀스는
__len__
과__getitem__
을 구현하고 인덱스 0부터 시작하여 요소를 한 번에 하나씩 차례로 가져올 수 있어야 한다. - 이터러블을 사용하면 메모리를 적게 사용하지만 n번째 요소를 한번에 얻을 수 없다. 이는 결국 메모리-CPU 트레이드오프이다.
1 2 |
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) |
__get_item__
은 다시 리스트에게 위임하기 때문에 음수 인덱스도 동작한다.
컨테이너 객체
- 컨테이너는
__contains__
메서드를 구현하면 되며,in
키워드가 발견될 때 호출된다. 일반적으로Boolean
값을 반환한다.
1 2 |
element in container |
- 이 코드는 아래 코드처럼 해석된다.
1 2 |
container.__contains__(element) |
- 2차원 게임 지도에서 특정 위치에 표시를 해야한다고 생각해보자.
1 2 |
def mark_coordinate(grid, coord): if 0 <= coord.x < grid.width and 0 <= coord.y < grid.height: grid[coord] = MARKED |
- 코드의 의도가 무엇인지 이해하기 어렵고, 직관적이지 않다.
-
지도에서 자체적으로 영역을 판단해주고, 이를 더 작은 객체에 위임해보자.
1 2 |
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.heightclass 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.limitsdef mark_coordinate(grid, coord): if coord in grid: grid[coord] = MARKED |
객체의 동적인 속성
<myobject>.<myattribute>
를 호출하면 파이썬은 객체의 사전에서 <myattribute>
를 찾아서 __getattribute__
를 호출한다. 속성이 없으면 __getattr__
메서드에 이름을 파라미터로 전달하여 호출한다.
1 2 |
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("No attribute")dyn = DynamicAttributes("value")print(dyn.attribute) # valueprint(dyn.fallback_test) # [fallback resolved] testdyn.__dict__["fallback_new"] = "new value"print(dyn.fallback_new)# new valueprint(getattr(dyn, "something", "If not, this is default value."))# If not, this is default value. |
호출형(callable) 객체
__call__
을 사용하면 객체를 일반 함수처럼 호출할 수 있다.-
객체를 이렇게 사용하는 이유는 객체에는 상태가 있기 때문에 함수 호출 사이에 ㅈ어보를 저장할 수 있어 편리하기 때문이다.
-
다음은 입력된 파라미터가 동일한 값으로 몇 번이나 호출되었는지를 반환하는 객체를 만든 것이다.
1 2 |
from collections import defaultdictclass CallCount: def __init__(self): self._counts = defaultdict(int) def __call__(self, argument): self._counts[argument] += 1 return self._counts[argument]cc = CallCount()print(cc(1))# 1print(cc(2))# 1print(cc(1))# 2print(cc(1))# 3print(cc("something"))# 1 |
파이썬에서 유의할 점
변경 가능한(mutable) 파라미터의 기본 값
- 변경 가능한 객체를 함수의 기본 인자로 사용하면 안된다.
1 2 |
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})" |
-
이 코드에는 두가지 문제가 있는데, 하나는 변경 가능한 인자를 사용한 것, 그리고 함수 내에서 가변 객체를 수정했다는 것이다.
-
기본 값을 사용하여 함수를 호출하면 사전을 한 번만 생성하게 된다. 그리고 함수 내에서
pop
을 이용하였기 때문이 두번째 호출할 떄에는KeyError
가 발생하게 된다. - 따라서 기본 초기값으로
None
을 사용하고 함수 본문에서 기본 값을 할당하자.
내장(built-in) 타입 확장
- 리스트, 문자열, 사전과 같은 타입을 상속 받아 확장할 때에는
collections
모듈을 사용해야 한다. - 타입을 직접 확장하는 경우, CPython에서는 Python에서 확장된 메서드를 호출할 수가 없기 때문에 문제가 발생한다.
1 2 |
class BadList(list): def __getitem__(self, index): value = super().__getitem__(index) if index % 2 == 0: prefix = "Even" else: prefix = "Odd" return f"[{prefix}] {value}"bl = BadList((0, 1, 2, 3, 4))print(bl[0])print(bl[1])print("".join(bl))# TypeError: sequence item 0: expected str instance, int found |
- 새로 정의한
__getitem__
이 호출되지 않았음을 알 수 있다. list
대신UserList
를 사용하면 된다.
1 2 |
from collections import UserListclass GoodList(UserList): def __getitem__(self, index): value = super().__getitem__(index) if index % 2 == 0: prefix = "Even" else: prefix = "Odd" return f"[{prefix}] {value}" |