곰퓨타의 SW 이야기

CH 2. 파이썬스러운 코드 본문

TIL/클린코드

CH 2. 파이썬스러운 코드

곰퓨타 2023. 5. 2. 22:00

파이썬스럽다 (pythonic)

관용구 : 특정 작업을 수행하기 위해 코드를 작성하는 특별한 방법이다

파이썬에서 관용구를 따른 것을 파이썬스럽다(Pythonic)이라 한다.

1. 인덱스와 슬라이스

첫번쩨 요소의 인덱스는 0부터 시작한다.

파이썬은 음수 인덱스를 통해 끝에서부터 접근이 가능하다.

>>> my_numbers = (4,5,3,9)
>>> my_numbers[-1]
9
>>> my_numbers[-3]
5

하나의 요소를 얻는 것 외에도 slice를 사용하여 특정 구간의 요소를 구할 수 있다.

[x:y] --> x, x+1, ... y-1까지의 요소 (x번째 이상부터 y번째 미만까지)

x, y 파라미터가 없는 경우에는 튜플의 복사본을 만든다.

>>> my_numbers = (1,1,2,3,5,8,13,21)
>>> my_numbers[2:5]
(2,3,5)
>>> my_numbers[:3]
(1,1,2)
>>> my_numbers[3;]
(3,5,8,13,21)
>>> my_numbers[::]	# my_numbers[:]도 마찬가지로 복사본을 만든다.
(1,1,2,3,5,8,13,21)
>>> my_numbers[1:7:2]	# 1번과 7번 미만의 인덱스 사이에 있는 요소를 두 칸씩 점프
(1,3,8)

slice함수 내장 객체

>>> interval = slice(1,7,2)
>>> my_numbers[interval]
(1,3,8)

>>> interval = slice(None, 3)
>>> my_numbers[interval] == my_numbers[:3]
True

 

튜플, 문자열, 리스트의 특정 요소를 가져오려고 한다면 for 루프를 돌며 수작업으로 요소를 선택하지 말고 이와 같은 방법을 사용하는 것이 좋다.

 

자체 시퀀스 생성

이는 __getitem__이라는 매직 매서드(매직매서드는 파이썬에서 특수한 동작을 수행하기 위해 예약한 메서드로 이중 밑줄로 둘러싸여 있다.) 이는 myobject[key]와 같은 형태를 사용할 때 호출되는 메서드로 key에 해당하는 대괄호 안의 값을 파라미터로 전달한다.

 

업무 도메인에서 사용하는 사용자 정의 클래스에 __getitem__을 구현하려는 경우 파이썬스러운 접근 방식을 따르기 위해 몇 가지를 고려해야 한다.

클래스가 표준 라이브러리 객체를 감싸는 래퍼(wrapper)인 경우 기본 객체에 가능한 많은 동작을 위임할 수 있다. 즉 클래스가 리스트의 래퍼인 경우 리스트의 동일한 메서드를 호출하여 호환성을 유지할 수 있다.

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 인터페이스를 구현해야 한다. 작성한 클래스가 컨테이너나 사전과 같은 표준 데이터 타입처럼 동작하게 하려면 이 모듈을 구현하는 것이 좋다. 이러한 인터페이스를 상속받으면 해당 클래스가 어떤 클래스인지 바로 알 수가 있으며, 필요한 요건들을 강제로 구현하게 되기 때문이다.

 

2. 컨텍스트 관리자

  • __enter__와 __exit__ 두 개의 매직 매서드로 구성된다.
  • with 문은 enter 메서드를 호출하고 이 메서드가 무엇을 반환하든 as 이후에 지정된 변수에 할당된다.
  • 해당 블록에 대한 마지막 문장이 끝나면 컨텍스트가 종료되며 exit 메서드를 호출한다
  • 블록 내에 예외 또는 오류가 있는 경우에도 exit 메서드가 여전히 호출되며, 예외는 파라미터로 확인 가능하다.
    • True를 반환하면 잠재적으로 발생한 예외를 호출자에게 전파하지 않고 정지함을 의미하나 좋지 않은 습관이다.

 

import contextlib


run = print


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()

main()

 

 

컨텍스트 관리자 구현

  • contextlib 모듈을 활용한 컨텍스트 관리자 구현 가능
    • contextlib.contextmanager 데코레이터를 적용하면 해당 함수의 코드를 컨텍스트 관리자로 변환한다.
    • contextlib.ContextDecorator를 사용하면 with 문 없이 완전히 독립적인 실행이 가능하다.
      • 컨텍스트 관리자 내부에서 사용하고자 하는 객체를 얻을 수 없다.
      • e.g ) with offline_back() as bp: 처럼 사용할 수 없다.
    • with contextlib.suppress(DataConversionException)은 로직 자체적으로 처리하고 있음을 예외임을 명시하고 실패하지 않도록 한다.
import contextlib

@contextlib.contextmanager
def db_handler():
    try:
        stop_database()	# yield 전은 __enter__를 의미한다.
        yield	# yeild는 제네레이터임을 의미한다. yield 뒤에는 반환 값을 작성
    finally:
        start_database()	# finally 구문은 __exit__를 의미한다.


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")


def main():
    with DBHandler():
        db_backup()

    with db_handler():
        db_backup()

    offline_backup()


if __name__ == "__main__":
    main()

 

3. comprehension 과 할당 표현식

import re
from typing import Iterable, Set

ARN_REGEX = re.compile(r"arn:aws:[a-z0-9\-]*:[a-z0-9\-]*:(?P<account_id>\d+):.*")


def collect_account_ids_from_arns(arns: Iterable[str]) -> Set[str]:
    """Given several ARNs in the form
        arn:partition:service:region:account-id:resource-id
    Collect the unique account IDs found on those strings, and return them.
    """
    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_arns2(arns: Iterable[str]) -> Set[str]:
    matched_arns = filter(None, (re.match(ARN_REGEX, arn) for arn in arns))
    return {m.groupdict()["account_id"] for m in matched_arns}


def collect_account_ids_from_arns3(arns: Iterable[str]) -> Set[str]:
    return {
        matched.groupdict()["account_id"]
        for arn in arns
        if (matched := re.match(ARN_REGEX, arn)) is not None
    }

 

4. 프로퍼티, 속성 (attribute)와 객체 메서드의 다른 타입들

  • 파이썬 객체의 모든 프로퍼티와 함수는 public 이다.
  • 따라서 엄격한 경계사항은 없지만 밑줄로 시작하는 속성은 해당 객체에 대해 private를 의미하며 외부에서 호출하지 않기를 기대한다.

 

파이썬에서의 밑줄

  • 이중 밑줄은 여러 번 확장되는 클래스의 메서드를 이름 충돌 없이 오버라이드하기 위해 만들어진다.
  • 이중 밑줄이 아닌 하나의 밑줄을 사용하는 파이썬의 관습을 지킬 것
class Connector:
    def __init__(self, source):
        self.source = source
        self._timeout = 60 # 하나의 밑줄

conn = Connector("postgresql://localhost")

conn._timeout = 70
print(conn.__dict__)

# {'source': 'postgresql://localhost', '_timeout': 70}

 

class Connector:
    def __init__(self, source):
        self.source = source
        self.__timeout = 60 # 두 개의 밑줄

conn = Connector("postgresql://localhost")

conn.__timeout # "접근할 수 없다"가 아닌 "존재하지 않는다"는 오류 발생
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-8-bd7c24031309> in <module>
      6 conn = Connector("postgresql://localhost")
      7 
----> 8 conn.__timeout # "접근할 수 없다"가 아닌 "존재하지 않는다"는 오류 발생

AttributeError: 'Connector' object has no attribute '__timeout'


conn._Connector__timeout = 80 # __함수에 대한 접근 방법
print(conn.__dict__)
{'source': 'postgresql://localhost', '_Connector__timeout': 80}

 

프로퍼티

  • 객체의 어떤 속성에 대한 접근을 제어하려는 경우 사용한다. (잘못된 정보의 입력 제한 등)
  • getter, setter 의 역할
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 -90 <= lat_value <= 90:
            self._latitude = lat_value
        else:
            raise ValueError(f"{lat_value} is an invalid value for latitude")

    @property
    def longitude(self) -> float:
        return self._longitude

    @longitude.setter
    def longitude(self, long_value: float) -> None:
        if -180 <= long_value <= 180:
            self._longitude = long_value
        else:
            raise ValueError(f"{long_value} is an invalid value for longitude")

 

이터러블 객체

  • 반복 가능한지 확인하기 위해 파이썬은 고수준에서 다음 두 가지를 차례로 검사한다.
    • 객체가 __next__ 나 __iter__이터레이터 메소드 중 하나를 포함하는지 여부
    • 객체가 시퀀스이고 __len__과 __getitem__을 모두 가졌는지 여부

이터러블 객체 만들기

  • 객체를 반복하려고 하면 파이썬은 해당 객체의 iter() 함수 호출
  • for문은 StopIteration 예외가 발생할 떄까지 next()를 호출하는 것과 같다.
  • 한 번 실행하면 끝의 날짜에 도달한 상태이므로 이후에 호출하면 계속 StopIteration 예외가 발생하는 문제
  • 제너레이터를 사용하면 이런 문제를 해결할 수 있다. --> 컨테이너 이터러블

 

시퀀스 만들기

  • 이터러블은 메모리를 적게 사용하지만 n번째 요소를 얻기 위한 시간복잡도는 O(n) 이다.
  • 시퀀스로 구현하면 더 많은 메모리가 사용되지만 (모든 것을 보관해야 하므로) 특정 요소를 가져오기 위한 인덱싱 시간 복잡도는 O(1)로 상수에 가능하다.
from datetime import timedelta


class DateRangeIterable:
    """An iterable that contains its own iterator object."""

    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


class DateRangeContainerIterable:
    """An range that builds its iteration through a generator."""

    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)


class DateRangeSequence:
    """An range created by wrapping a sequence."""

    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)

 

 

컨테이너 객체

  • 컨테이너는 __contain__메서드를 구현한 객체이다
  • 일반적으로 Boolean 을 반환하며 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] = 1

    if coord in grid:
        grid[coord] = 1


class Boundaries:
    def __init__(self, width, height):
        self.width = width
        self.height = heigh

    def __contains__(self, coord):
        x, y = coord
        return 0 <= x < self.width and 0 <= y < self.height


class 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.limits

 

객체의 동적인 속성

  • 파이썬은 객체를 호출하면 객체 사전에서 __getattribute__를 호출한다.
  • 객체에 찾고 있는 속성이 없는 경우 객체 이름을 파라미터로 getattr 이라는 추가 메서드가 호출된다.
class DynamicAttributes:
    """
    >>> 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'
    """

    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__} has no attribute {attr}"
        )

 

호출형(callable) 객체

  • __call__을 사용하면 객체를 일반 함수처럼 호출할 수 있다.
  • 여기에 전달된 모든 파라미터는 call 메서드에 그대로 전달된다.
from collections import defaultdict


class CallCount:
    """
    >>> cc = CallCount()
    >>> cc(1)
    1
    >>> cc(2)
    1
    >>> cc(1)
    2
    >>> cc(1)
    3
    >>> cc("something")
    1
    >>> callable(cc)
    True
    """

    def __init__(self):
        self._counts = defaultdict(int)

    def __call__(self, argument):
        self._counts[argument] += 1
        return self._counts[argument]

 

5. 파이썬에서 유의할 점

변경 가능한 파라미터의 기본 값

  • 변경 가능한 객체를 함수의 기본 인자로 사용하면 안 된다.
from collections import UserList


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})"


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})"

 

내장(build-in) 타입 확장

  • 리스트, 문자열, 사전과 같은 내장 타입을 확장하는 올바른 방법은 collections 모듈을 사용하는 것
  • 내장 함수에서 override가 동작하지 않는다.
class BadList(list):
    def __getitem__(self, index):
        value = super().__getitem__(index)
        if index % 2 == 0:
            prefix = "even"
        else:
            prefix = "odd"
        return f"[{prefix}] {value}"


class GoodList(UserList):
    def __getitem__(self, index):
        value = super().__getitem__(index)
        if index % 2 == 0:
            prefix = "even"
        else:
            prefix = "odd"
        return f"[{prefix}] {value}"

 

 

 

 

Reference

https://github.com/PacktPublishing/Clean-Code-in-Python-Second-Edition/tree/main/book/src/ch02

https://github.com/dream2globe/CleanCodeInPython/blob/master/Ch2_PythonicCode.ipynb

http://www.yes24.com/Product/Goods/114667254

Comments