이글은 책 "파이썬 클린 코드" ch2의 내용을 읽고 요약 및 추가한 내용입니다.
pythonic 코드란?
- 일종의 python 언어에서 사용되는 관용구
Pythonic 코드를 작성하는 이유
- 일반적으로 더 나은 성능을 보임
- 코드도 더 작고 이해하기 쉬움
인덱스와 슬라이스
- 파이썬은 음수 인덱스를 사용하여 끝에서부터 접근이 가능
my_numbers = (4, 5, 3, 9)
print(my_numbers[-1]) # 9
print(my_numbers[-3]) # 5
- slice를 이용하여 특정 구간의 요소를 얻을 수 있음
- 끝 인덱스는 제외
my_numbers = (1, 1, 2, 3, 5, 8, 13, 21)
print(my_numbers[2:5]) # (2, 3, 5)
print(my_numbers[::]) # (1, 1, 2, 3, 5, 8, 13, 21)
간격 값 조절
- index를 2칸씩 점프
my_numbers = (1, 1, 2, 3, 5, 8, 13, 21)
print(my_numbers[1:7:2]) # 1, 3, 8
- slice 함수를 직접 호출할 수도 있음
my_numbers = (1, 1, 2, 3, 5, 8, 13, 21)
interval = slice(1, 7, 2)
print(my_numbers[interval]) # (1, 3, 8)
자체 시퀀스 생성
- indexing 및 slice는 __getitem__ 이라는 매직 메서드 덕분에 동작
- 클래스가 시퀀스임을 선언하기 위해 collections.abc모듈의 Sequence 인터페이스를 구현해야 함
class C(Sequence): # Direct inheritance
def __init__(self): ... # Extra method not required by the ABC
def __getitem__(self, index): ... # Required abstract method
def __len__(self): ... # Required abstract method
def count(self, value): ... # Optionally override a mixin method
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)
items = Items(1, 2, 3)
print(items[2]) # 3
print(items[0:2]) # [1, 2]
- 다음 사항에 유의해 시퀀스를 구현해야 함
- 범위로 인덱싱하는 결과는 해당 클래스와 같은 타입의 인스턴스여야 한다. -> 지키지 않는 경우 오류 발생 가능성
- 슬라이스에 의해 제공된 범위는 마지막 요소를 제외해야 한다. -> 파이썬 언어와 일관성 유지
컨텍스트 관리자(context manager)
- 사전 조건과 사후 조건이 있는 일부 코드를 실행해야 하는 상황에 유용
- 리소스 관리와 관련된 컨텍스트 관리자 자주 볼 수 있음
def process_file(fd):
line = fd.readline()
print(line)
fd = open("test.txt")
try:
process_file(fd)
finally:
print("file closed")
fd.close()
123 file closed
똑같은 기능을 매우 우아하게 파이썬 스럽게 구현
def process_file(fd):
line = fd.readline()
print(line)
with open("test.txt") as fd:
process_file(fd)
context manager는 2개의 매직 메소드로 구성
- __enter__ : with 문이 호출
- __exit__ : with 블록의 마지막 문장이 끄나면 컨텍스트가 종료되고 __exit__가 호출됨
context manager 블록 내에 예외 또는 오류가 있어도 __exit__ 메소드는 여전히 호출되므로 정리 조건을 안정하게 실행하는데 편함
예시: 데이터베이스 백업
- 백업은 오프라인 상태에서 해야함 (데이터베이스가 실행되고 있지 않는 동안) → 서비스 중지 필요
방법 1
- 서비스를 중지 → 백업 → 예외 및 특이사항 처리 → 서비스 다시 처리 과정을 단일 함수로 만드는 것
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 를 사용한 블록 내부에서 context manager 결과를 사용하지 않음
- __enter__에서 무언가를 반환하는 것이 좋은 습관
- main() 에서 유지보수 작업과 상관없이 백업을 실행. 백업에 오류가 있어도 여전히 __exit__을 호출
- __exit__의 반환 값을 잘 생각해야 함. True를 반환하면 잠재적으로 발생한 예외를 호출자에게 전파하지 않고 멈춘다는 뜻으로 예외를 삼키는 것은 좋지 않은 습관
Context manager 구현
- contextlib.contextmanager 데코레이터 사용
import contextlib
@contextlib.contextmanager
def db_handler():
try:
stop_database() (1)
yield (2)
finally:
start_database() (4)
with db_handler():
db_backup() (3)
@contextlib.contextmanager
- 해당 함수의 코드를 context manager로 변환
- 함수는 generator라는 특수한 함수의 형태여야 하는데 이 함수는 코드의 문장을 __enter__와 __exit__매직 메소드로 분리한다.
- yield 키워드 이전이 __enter__ 메소드의 일부처럼 취급
- yield 키워드 다음에 오는 모든 것들을 __exit__로직으로 볼 수 있음
2. contextlib.ContextDecorator 클래스 사용
import contextlib
def stop_database():
print("stop database")
def start_database():
print("start database")
def run(text):
print(text)
class dbhandler_decorator(contextlib.ContextDecorator):
def __enter__(self):
stop_database()
return self
def __exit__(self, ext_type, ex_value, ex_traceback):
start_database()
@dbhandler_decorator()
def offline_backup():
run("pg_dump database")
offline_backup()
stop database
pg_dump database
start database
- with 문이 없고 함수를 호출하면 offline_backup 함수가 context manager 안에서 자동으로 실행됨
- 원본 함수를 래핑하는 데코레이터 형태로 사용
- 단점은 완전히 독립적이라 데코레이터는 함수에 대해 아무것도 모름 (사실 좋은 특성)
contextlib 의 추가적인 기능
import contextlib
with contextlib.suppress(DataConversionException):
parse_data(nput_json_or_dict)
- 안전하다고 확신되는 경우 해당 예외를 무시하는 기능
- DataConversionException이라고 표현된 예외가 발생하는 경우 parse_data 함수를 실행
컴프리헨션과 할당 표현식
- 코드를 간결하게 작성할 수 있고 가독성이 높아짐
def run_calculation(i):
return i
numbers = []
for i in range(10):
numbers.append(run_calculation(i))
print(numbers) # [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
위의 코드를 아래와 같이 바로 리스트 컴프리헨션으로 만들 수 있음
numbers = [run_calculation(i) for i in range(10)]
- list.append를 반복적으로 호출하는 대신 단일 파이썬 명령어를 호출하므로 일반적으로 더 나은 성능을 보임
dis 패키지를 이용한 어셈블리코드 비교각 assembly 코드 (list comprehension)
import dis
def run_calculation(i):
return i
def list_comprehension():
numbers = [run_calculation(i) for i in range(10)]
return numbers
# Disassemble the list comprehension function
dis.dis(list_comprehension)
def for_loop():
numbers = []
for i in range(10):
numbers.append(run_calculation(i))
return numbers
# Disassemble the for loop function
dis.dis(for_loop)
각 assembly 코드 (list comprehension)
6 0 LOAD_CONST 1 (<code object <listcomp> at 0x7f8e5a78f710, file "example.py", line 6>)
2 LOAD_CONST 2 ('list_comprehension.<locals>.<listcomp>')
4 **MAKE_FUNCTION** 0
6 LOAD_GLOBAL 0 (range)
8 LOAD_CONST 3 (10)
10 **CALL_FUNCTION** 1
12 GET_ITER
14 CALL_FUNCTION 1
16 RETURN_VALUE
# for loop
10 0 BUILD_LIST 0
2 STORE_FAST 0 (numbers)
11 4 SETUP_LOOP 28 (to 34)
6 LOAD_GLOBAL 0 (range)
8 LOAD_CONST 1 (10)
10 CALL_FUNCTION 1
12 GET_ITER
>> 14 FOR_ITER 16 (to 32)
16 STORE_FAST 1 (i)
12 18 LOAD_FAST 0 (numbers)
20 LOAD_ATTR 1 (append)
22 LOAD_GLOBAL 2 (run_calculation)
24 LOAD_FAST 1 (i)
26 CALL_FUNCTION 1
28 CALL_METHOD 1
30 POP_TOP
32 JUMP_ABSOLUTE 14
>> 34 POP_BLOCK
13 >> 36 LOAD_FAST 0 (numbers)
38 RETURN_VALUE
리스트 컴프리헨션 예시
import re
from typing import Iterable, Set
# Define the regex pattern for matching the ARN format
ARN_REGEX = r"arn:(?P<partition>[^:]+):(?P<service>[^:]+):(?P<region>[^:]*):(?P<account_id>[^:]+):(?P<resource_id>[^:]+)"
def collect_account_ids_from_arns(arns: Iterable[str]) -> Set[str]:
"""
arn:partition:service:region:account-id:resource-id 형태의 ARN들이 주어진 경우 account-id를 찾아서 반환
"""
collected_account_ids = set()
for arn in arns:
matched = re.match(ARN_REGEX, arn)
if matched is not None:
account_id = matched.groupdict()["account_id"]
collected_account_ids.add(account_id)
return collected_account_ids
# Example usage
arns = [
"arn:aws:iam::123456789012:user/David",
"arn:aws:iam::987654321098:role/Admin",
"arn:aws:iam::123456789012:group/Developers",
]
unique_account_ids = collect_account_ids_from_arns(arns)
print(unique_account_ids)
# {'123456789012', '987654321098'}
위 코드 중 collect_account_ids_from_arns 함수를 집중해서 보면,
def collect_account_ids_from_arns(arns: Iterable[str]) -> Set[str]:
"""
arn:partition:service:region:account-id:resource-id 형태의 ARN들이 주어진 경우 account-id를 찾아서 반환
"""
collected_account_ids = set()
for arn in arns:
matched = re.match(ARN_REGEX, arn)
if matched is not None:
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]:
"""
arn:partition:service:region:account-id:resource-id 형태의 ARN들이 주어진 경우 account-id를 찾아서 반환
"""
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이후에는 할당표현식을 이용해 한문장으로 다시 작성 가능
def collect_account_ids_from_arns(arns: Iterable[str]) -> Set[str]:
"""
arn:partition:service:region:account-id:resource-id 형태의 ARN들이 주어진 경우 account-id를 찾아서 반환
"""
return {
matched.groupdict()["account_id"]
for arn in arns
if (matched := re.match(ARN_REGEX, arn)) is not None
}
- 정규식 이용한 match 결과들 중 None이 아닌 것들만 matched 변수에 저장되고 이를 다시 사용
더 간결한 코드가 항상 더 나은 코드를 의미하는 것은 아니지만 분명 두번째나 세번째 코드가 첫번째 코드보다는 낫다는 점에서는 의심의 여지가 없음
프로퍼티, 속성(attribute)과 객체 메서드의 다른 타입들
파이썬에서의 밑줄
class Connector:
def __init__(self, source):
self.source = source
self._timeout = 60
conn = Connector("postgresql://localhost")
print(conn.source) # postgresql://localhost
print(conn._timeout) # 60
print(conn.__dict__) # {'source': 'postgresql://localhost', '_timeout': 60}
- source와 timeout이라는 2개의 속성을 가짐
- source는 public, timeout은 private
- 하지만 실제로는 두 개의 속성에 모두 접근 가능
- _timeout는 connector 자체에서만 사용되고 바깥에서는 호출하지 않을 것이므로 외부 인터페이스를 고려하지 않고 리팩토링 가능
2개의 밑줄은? (__timeout) → name mangling 으로 실제로 다른 이름을 만듦
- _<classname>__<attribute-name>
class Connector:
def __init__(self, source):
self.source = source
self.__timeout = 60
conn = Connector("postgresql://localhost")
print(conn.source) # postgresql://localhost
print(conn.__dict__)
# {'source': 'postgresql://localhost', '_Connector__timeout': 60}
- __timeout → 실제 이름은_Connector__timeout 이 됨
- 이는 여러번 확장되는 클래스의 메소드 이름을 충돌없이 오버라이드 하기 위해 만들어진거로 pythonic code의 예가 아님
결론
⇒ 속성을 private으로 정의하는 경우 하나의 밑줄 사용
프로퍼티(Property)
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:
print("here")
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:
if long_value not in range(-180, 180+1):
raise ValueError(f"유효하지 않은 경도 값: {long_value}")
self._longitude = long_value
coord = Coordinate(10, 10)
print(coord.latitude)
coord.latitude = 190 # ValueError: 유호하지 않은 위도 값: 190
- property 데코레이터는 무언가에 응답하기 위한 쿼리
- setter는 무언가를 하기 위한 커맨드
둘을 분리하는 것이 명령-쿼리 분리 원칙을 따르는 좋은 방법
보다 간결한 구문으로 클래스 만들기
객체의 값을 초기화하는 일반적인 보일러플레이트
- 보일러 플레이트: 모든 프로젝트에서 반복해서 사용하는 코드
def __init__(self, x, y, ...):
self.x = x
self.y = y
- 파이썬 3.7부터는 dataclasses 모듈을 사용하여 위 코드를 훨씬 단순화할 수 있다 (PEP-557)
- @dataclass 데코레이터를 제공
- 클래스에 적용하면 모든 클래스의 속성에 대해서 마치 __init__ 메소드에서 정의한 것처럼 인스턴스 속성으로 처리
- @dataclass 데코레이터가 __init__ 메소드를 자동 생성
- field라는 객체 제공해서 해당 속성에 특별한 특징이 있음을 표시
- 속성 중 하나가 list처럼 변경가능한 mutable 데이터 타입인 경우 __init__에서 비어 있는 리스트를 할당할 수 없고 대신에 None으로 초기화한 다음에 인스턴스마다 적절한 값으로 다시 초기화 해야함
from dataclasses import dataclass
@dataclass
class Foo:
bar: list = []
# ValueError: mutable default <class 'list'> for field a is not allowed: use default_factory
- 안되는 이유는 위의 bar 변수가 class variable이라 모든 Foo 객체들 사이에서 공유되기 때문
class C:
x = [] # class variable
def add(self, element):
self.x.append(element)
c1 = C()
c2 = C()
c1.add(1)
c2.add(2)
print(c1.x) # [1, 2]
print(c2.x) # [1, 2]
아래처럼 default_factory 파라미터에 list 를 전달하여 초기값을 지정할 수 있도록 하면 됨
from dataclasses import dataclass, field
@dataclass
class Foo:
bar = field(default_factory=list)
__init__ 메소드가 없는데 초기화 직후 유효성 검사를 하고 싶다면?
⇒ __post_init__에서 처리 가능
'Python' 카테고리의 다른 글
pathlib 모듈 (0) | 2024.10.20 |
---|---|
[책리뷰] 파이썬 클린 코드 Chapter 2. Pythonic 코드 (2) (0) | 2024.09.29 |
super() (0) | 2024.09.15 |
The Walrus Operator: Python's Assignment Expressions (바다코끼리 연산자) (0) | 2024.08.31 |
URL 다루기 위한 python의 built-in 패키지: urllib (0) | 2024.08.25 |