반응형

이글은 책 "파이썬 클린 코드" 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 구현

  1. 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__에서 처리 가능

반응형

+ Recent posts