이 장에서는 파이썬스럽게 표현하는 방법을 배운다.
우리는 왜 파이썬스러운 코드를 작성해야 할까?
>>> my_numbers = (4, 5, 3, 9)
>>> my_numbers[-1]
9
>>> my_numbers[-4]
4
>>> my_numbers[-5]
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
IndexError: tuple index out of range
요소 | 4 | 5 | 3 | 9 |
---|---|---|---|---|
인덱스 | 0 | 1 | 2 | 3 |
인덱스 | -4 | -3 | -2 | -1 |
>>> my_numbers = (1, 1, 2, 3, 5, 8, 13, 21)
>>> my_numbers[2:5]
(2, 3, 5)
[시작:끝] 단, 끝 인덱스는 제외한 구간의 값을 가져온다.
따라서, 결괏값이 (2, 3, 5, 8)
이 아닌 (2, 3, 5)
가 된다.
>>> my_numbers = (1, 1, 2, 3, 5, 8, 13, 21)
>>> my_numbers[:3]
(1, 1, 2)
>>> my_numbers = (1, 1, 2, 3, 5, 8, 13, 21)
>>> my_numbers[3:]
(3, 5, 8, 13, 21)
>>> my_numbers[:]
(1, 1, 2, 3, 5, 8, 13, 21)
이 경우 my_numbers
의 복사본을 만든다.
>>> my_numbers[::]
(1, 1, 2, 3, 5, 8, 13, 21)
마찬가지로 이 경우에도 my_numbers
의 복사본을 만든다.
복사본
이라고 표현하길래, 설마 복사본이라는게 깊은 복사를 말하는건가? 하고 궁금해졌다.
id 값을 확인해보고 싶었다.
>>> id(my_numbers)
4338114848
>>> id(my_numbers[:])
4338114848
>>> id(my_numbers[::])
4338114848
역시나 얕은 복사다.
>>> my_numbers[1:7:2]
(1, 3, 8)
# 내장 객체 slice 사용
>>> interval = slice(1, 7, 2)
>>> my_numbers[interval]
(1, 3, 8)
>>> interval = slice(None, 3)
>>> interval
slice(None, 3, None)
>>> my_numbers[interval]
(1, 1, 2)
slice(시작, 중지, 간격) 중 지정하지 않은 파라미터는 None으로 설정되고 있다.
TIP
튜플, 문자열, 리스트의 특정 요소를 가져오려고 한다면 for루프를 돌며 수작업으로 요소를 선택하지 말고 이와 같은 방법을 사용하자.
시퀀스는 __getitem__
과 __len__
을 모두 구현한 객체다.
그렇다면 표준 라이브러리 객체를 감싸는 사용자 정의 클래스를 만들어 보자.
예를 들어, 만들려고 하는 클래스가 리스트의 래퍼라면,
리스트의 동일한 메서드를 호출하여 호환성을 유지할 수 있다.
from collections.abc import Sequence
class Items:
def __init__(self, *values):
# 이로써, self._valus는 리스트다.
# Items 클래스는 리스트의 래퍼 클래스가 됐다.
self._values = list(values)
def __len__(self):
return len(self._values)
def __getitem__(self, item):
return self._values.__getitem__(item)
1) 범위 인덱싱 결과는 해당 클래스와 같은 타입의 인스턴스여야 한다.
ex) range가 대표적인 좋은 예
>>> range(1, 100)[25: 50]
range(26, 51)
>> list(range(1, 10))
[1, 2, 3, 4, 5, 6, 7, 8, 9]
어떤 중요한 작업 전후에 실행해야하는 코드가 있을 때 유요한 기능이다.
일반적으로 리소스 관리와 관련하여 자주 볼 수 있다.
# 우아하지 않은 방법
fd = open(file_name)
try:
process_file(fd)
finally:
fd.close()
# 우아한 방법
with open(fine_name) as fd:
process_file(fd)
with문
은 컨텍스트 관리자로 진입하게 한다.
예외 발생 시에도 블록이 완료되며 파일이 닫힌다.
컨텍스트 관리자는 __enter__
와 __exit__
두 개의 매직 메서드로 구현된다.
def stop_database():
run("systemctl stop postgresql.service")
def start_database():
run("system start postgresql.service")
def db_backup():
run("pg_dump database")
class DBHandler:
# 이 케이스의 경우 __enter__의 리턴값은 쓸모가 없다.
# 하지만 일반적으로 __enter__가 무언가를 반환하도록 하는 것이 좋다.
def __enter__(self):
stop_database()
return self
# __exit__ 메서드 실행 시, 발생한 에러를 파라미터로 받을 수 있다.
# 블록에 예외가 없다면 모두 None이 할당된다.
def __exit__(self, exc_type, exc_val, exc_tb):
start_database()
def main():
with DBHandler():
db_backup()
__exit__
의 리턴값에 유의해야 한다.
특별한 작업을 할 필요가 없다면 아무것도 반환하지 않아도 된다.
만약 return True를 하게되면, 발생한 에러를 호출자에게 전파하지 않겠다는 것을 의미한다. 절대로 오류를 조용히 무시하면 안 된다.
contextlib 모듈을 사용하여 보다 쉽게 컨텍스트 관리자를 구현할 수 있다.
import contextlib
@contextlib.contextmanager
def db_handler():
try:
stop_database()
yield
finally:
start_database()
with db_handler():
db_backup()
yield문
앞까지 선언된 모든 코드는 __enter__
메서드의 일부처럼 취급된다.db_backup 로직이 실행이 완료되면, yield 다음의 코드가 실행된다.
즉, yield 문 이후의 코드는 __exit__
의 로직으로 볼 수 있다.
yield 문
을 기준으로 __enter__
과 __exit__
이 나뉜다.
db_backup 함수를 수정하기 편리하다.
컨텍스트 관리자 안에서 실행될 함수에 데코레이터를 적용하기 위한 로직을 제공하는 믹스인 클래스(유틸리티 클래스)다. 컨텍스트 관리자 자체의 로직은 직접 매직 메서드를 구현해야 한다.
class dbhandler_decorator(contextlib.ContextDecorator):
def __enter__(self):
stop_database()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
start_database()
@dbhandler_decorator
def offline_backup():
run("pg_dump database")
위 두 가지 방법의 차이점은 무엇일까?
import contextlib
with contextlib.suppress(DataConversionException):
parse_data(input_json_or_dict)
안전하다고 확신하는 경우 특정 예외를 무시하는 기능이다.
import contextlib
def render_data():
print(1 + "1")
with contextlib.suppress(ValueError):
render_data()
결과
print(1 +"1")
TypeError: unsupported operand type(s) for +: 'int' and 'str'
TypeError를 무시하도록 수정해보자.
with contextlib.suppress(TypeError):
render_data()
결과
Process finished with exit code 0
컨텍스트 관리자는 파이썬을 차별화하는 상당히 독특한 기능이다.
따라서 컨텍스트 관리자를 자주 사용하자.
컴프리핸션을 사용하면 코드를 보다 간결하게 작성할 수 있고, 가독성도 높아진다.
하지만 컴프리핸션을 적용하면 오히려 코드가 더 복잡해질 수 있다.
즉, for문을 사용하는 것이 나은 선택일 수 있다는 말이다.
이때 할당 표현식도 함께 고려해보자.
numbers = []
for i in range(10):
# list.appned를 반복적으로 호출(성능 좋지 않다.)
numbers.append(i)
numbers = [run_calculation(i) for i in range(10)]
[할당 표현식 사용 전]
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):
# None이 아닌 것들만 남긴다.
matched_arns = filter(None, (re.match(ARN_REGEX, arn) for arn in arns))
return {m.groupdict()["account_id"] for m in matched_arns}
[할당 표현식(python 3.8, PEP-572) 도입 이후]
def collect_account_ids_from_arns(arns):
return {
matched.groupdict()["account_id"]
for arn in arns
if (matched := re.match(ARN_REGEX, arn)) is not None
}
더 간결한 코드가 항상 더 나은 코드를 의미하는 것은 아니다.
TIP
컴프리핸션의 가독성을 생각해야 하며, 정말로 이해하기 쉬운 코드가 되는 것이 아니라면, 한 줄 코드를 만들기 위해 노력하지 않도록 유의하자.
파이썬에서는 변수명 지정과 관련하여 몇 가지 네이밍 컨벤션이 있다.
밑줄로 시작하는 속성은 private 속성으로써, 외부에서 호출되지 않기를 기대한다는 의미다.
>> conn = Connector("postgresql://localhost")
>> conn.source
'postgresql://localhost'
>> conn._timeout
60
>> conn.__dict__
{'source': 'postgresql://localhost', '_timeout': 60}
TIP
클래스는 외부 호출 객체와 관련된 속성과 메서드만 노출해야 한다. 즉 객체의 인터페이스로 공개하는 용도가 아니라면 모든 멤버는 접두사로 하나의 밑줄을 사용하는 것이 좋다.
private 속성과 private 메서드를 만들 수 있다는 오해가 있다.
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("mysql://localhost")
>> conn.connect()
connecting with 60s
>> conn.__timeout
Traceback (most recent call last):
File "/Applications/PyCharm.app/Contents/plugins/python/helpers/pydev/pydevconsole.py", line 364, in runcode
coro = func()
File "<input>", line 1, in <module>
AttributeError: 'Connector' object has no attribute '__timeout'
"이것은 private이다" 또는 "이것은 접근할 수 없다" 식의 에러가 아니다.
AttributeError로 단지 존재하지 않는다고만 하고 있다.
밑줄 두 개를 사용하면 파이썬은 다른 이름을 만든다.
이름 맹글링(name mangling)으로, 이중 밑줄을 사용한 변수의 이름을 _<class_name>__<attribute-name>
형태로 변경해 버린다.
따라서, _Connector__timeout
이라는 속성이 생기고 접근이 가능하다.
>> vars(conn)
{'source': 'mysql://localhost', '_Connector__timeout': 60}
>> conn._Connector__timeout = 15
>> conn.connect()
connecting with 15s
파이썬에서 이중 밑줄을 사용하는 것은 완전히 다른 경우를 위한 것이다.
여러 번 확장되는 클래스의 메서드를 이름 충돌 없이 오버라이드하기 위해서 만들어졌다.
일반적으로 객체 지향 설계에서는 도메인 엔터티를 추상화하는 객체를 만든다.
이러한 객체는 어떤 동작이나 데이터를 캡슐화할 수 있다.
그리고 종종 데이터의 정확성이 객체를 생성할 수 있는지 여부를 결정한다.
다시 말하면, 일부 엔터티는 데이터가 특정 값을 가질 경우에만 존재할 수 있고, 잘못된 값을 가진 경우에는 존재할 수 없다.
이것이(일반적으로 setter 작업에 사용되는) 유효성 검사 메서드를 만드는 이유이다.
그러나 파이썬에서는 프로퍼티를 사용하여 setter와 getter 메서드를 더 간결하게 캡슐화할 수 있다.
ex) 좌표 값을 처리하는 지리 시스템
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 longitude(self, long_value: float) -> None:
여기에서 프로퍼티는 latitude와 longitude를 정의하기 위해 사용됐다.
변수에 저장된 값을 반환하는 별도의 속성을 만들었다.
더 중요한 것은 사용자가 다음과 같은 방법으로 이러한 속성 중 하나를 수정하려는 경우이다.
coordinate.latitude = <new-latitude-value>
coordinate.longitude = <new-longitude-value>
@latitude.setter
, @longitude.setter
데코레이터로 선언된 유효성 검사 로직이 자동 호출된다.
TIP
객체의 모든 속성에 대해 get, set 메서드를 작성할 필요는 없다.
대부분의 경우 일반 속성을 사용하는 것으로 충분하다.
속성 값을 가져오거나 수정할 때 특별한 로직이 필요한 경우에만 프로퍼티를 사용하자.
프로퍼티는 명령-쿼리 분리 원칙
을 따르기 위한 좋은 방법이다.
명령-쿼리 분리 원칙은 객체의 메서드가 무언가의 상태를 변경하는 커맨드이거나 무언가의 값을 반환하는 쿼리이거나 둘 중 하나만 수행해야 한다는 것이다.
ex) set_email 메서드에 대한 고찰
if self.set_email("a@j.com")
이 코드는 무엇을 의미하는 것일까?
a@j.com으로 이메일을 설정하려는 것인가?
이미 이메일이 해당 값으로 설정 돼 있는지 확인하는 걸까?
아니면 동시에 이메일 값을 설정하고 유효한지 체크하려는 걸까?
프로퍼티를 사용하면 이러 혼동을 피할 수 있다.
@property 데코레이터는 무언가에 응답하기 위한 쿼리이고,
@<property_name>.setter는 무언가를 하기 위한 커맨드이다.
파이썬에서는 객체의 값을 초기화하는
일반적인 보일러 플레이트(boilerplate code: 모든 프로젝트에서 공통적으로 반복해서 사용되는 코드) 코드가 있다.
def __init__(self, x, y):
self.x = x
self.y = y
파이썬 3.7부터는 dataclasses
모듈을 사용하여 위의 코드를 훨씬 단순화할 수 있게 됐다.
__init__
메서드를 자동으로 생성한다.@dataclass
class Foo:
bar: List = []
# raise ValueError(f'mutable default {type(f.default)} for field '
# ValueError: mutable default <class 'list'> for field bar is not allowed: use default_factory
@dataclass
class Foo:
x: list = field(default_factory=list)
assert Foo().x is not Foo().x
from typing import List
from dataclasses import dataclass, field
R = 26
@dataclass
class RTrieNode:
size = R
value: int
next_: List["RTrieNode"] = field(default_factory=lambda: [None] * R)
def __post_init__(self):
if len(self.next_) != self.size:
raise ValueError(f"리스트(next_)의 길이가 유효하지 않음")
파이썬의 반복은 이터러블 프로토콜이라는 자체 프로토콜을 사용해 동작한다.
파이썬은 고수준에서 다음 두 가지를 차례로 검사한다.
__next__
나 __iter__
메서드 중 하나를 포함하는지 여부__len__
과 __getitem__
모두 가졌는지 여부객체를 반복하려고 하면 파이썬은 해당 객체의 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
@dataclass
class MyIterable:
start: int
end: int
def __post_init__(self):
if self.start > self.end:
raise ValueError("시작값이 더 큽니다")
def __iter__(self):
return self
def __next__(self):
if self.start > self.end:
raise StopIteration()
current = self.start
self.start += 1
return current
for i in MyIterable(1, 3):
print(i)
개선
@dataclass
class MyIterable:
start: int
end: int
def __post_init__(self):
if self.start > self.end:
raise ValueError("시작값이 더 큽니다")
def __iter__(self):
current = self.start
while current <= self.end:
yield current
current += 1
r1 = MyIterable(1, 3)
print(r1)
print(", ".join(map(str, r1)))
print(max(r1))
>>> from datetime import date
>>> for day in DateRangeIterable(date(2023, 1, 1), date(2023, 1, 5)):
print(day)
2023-01-01
2023-01-02
2023-01-03
2023-01-04
for 루프가 작동하는 원리는 StopIteration 예외가 발생할 때까지 next()를 호출하는 것과 같다.
r = DateRangeIterable(date(2023, 1, 1), date(2023, 1, 5))
next(r)
datetime.date(2023, 1, 1)
next(r)
datetime.date(2023, 1, 2)
next(r)
datetime.date(2023, 1, 3)
next(r)
datetime.date(2023, 1, 4)
next(r)
Traceback (most recent call last):
File "/Applications/PyCharm.app/Contents/plugins/python/helpers/pydev/pydevconsole.py", line 364, in runcode
coro = func()
File "<input>", line 1, in <module>
File "<input>", line 14, in __next__
StopIteration
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):
current_day = self.start_date
while current_day < self.end_date:
yield current_day
current_day += timedelta(days=1)
r1 = DateRangeIterable(date(2023, 1, 1), date(2023, 1,5))
", ".join(map(str, r1))
'2023-01-01, 2023-01-02, 2023-01-03, 2023-01-04'
max(r1)
datetime.date(2023, 1, 4)
달라진 점은 for 루프는 __iter__
를 호출하고 __iter__
는 다시 제너레이터를 생성한다는 것이다.
이러한 형태의 객체를 컨테이너 이터러블(container iterable)이라고 한다.
객체에 __iter__
메서드를 정의하지 않았지만 반복하기를 원하는 경우도 있다.
iter() 함수는 객체에 __iter__
가 정의되어 있지 않으면 __getitem__
을 찾고,
없으면 TypeError를 발생시킨다.
이터러블을 사용하면 메모리를 적게 사용하지만 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(2023, 1, 1), date(2023, 1, 5))
for day in s1:
print(day)
2023-01-01
2023-01-02
2023-01-03
2023-01-04
>> s1[1]
datetime.date(2023, 1, 2)
>> s1[0]
datetime.date(2023, 1, 1)
>> s1[-1]
datetime.date(2023, 1, 4)
>> s1[-2]
datetime.date(2023, 1, 3)
두 가지 구현 중 어떤 것을 사용할지 결정할 때 메모리와 CPU사이의 트레이드오프를 계산해보자. 일반적으로 이터레이션이 더 좋은 선택이지만 항상 그런 건 아니다.
컨테이너 객체는 __contains__
메서드를 구현한 객체다.
이 메서드는 in
키워드가 발견될 때 호출된다.
element in container
파이썬에서 이 코드는 다음과 같이 해석한다.
container.__contains__(element)
def mark_coordinate(grid, coord):
if 0 <= coord.x < grid.width and 0 <= coord.y < grid.height:
grid[coord] = MARKED
능동적으로 협력하는 객체 세계를 구축해보자.
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 __int__(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
__getattr__
매직 메서드를 사용하면 객체가 속성에 접근하는 방법을 제어할 수 있다.
우리가 <myobject>.<myattribute>
형태로 객체 속성에 접근할 때, 파이썬은 객체의 __dict__
사전에서 검색을 한다.
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
Traceback (most recent call last):
File "/Applications/PyCharm.app/Contents/plugins/python/helpers/pydev/pydevconsole.py", line 364, in runcode
coro = func()
File "<input>", line 1, in <module>
AttributeError: 'DynamicAttributes' object has no attribute 'fallback_test'
>> dyn.__dict__["fallback_new"] = "new value"
>> dyn.fallback_new
'new value'
>> getattr(dyn, "something", "default")
'default'
__getattr__
유용한 점
단, __getattr__
남용 시, 가독성이 떨어진다.
객체를 일반 함수처럼 호출하면 __call__
매직 메서드가 호출된다.
객체 호출 시 사용된 모든 파라미터는 __call__
메서드에 그대로 전달된다.
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]
>> cc = CallCount()
>> cc(1)
1
>> cc(2)
1
>> cc(2)
2
>> cc(2)
3
cc("something")
1
>> callable(cc)
True